v0.9.15 updated branding modal

This commit is contained in:
2026-03-14 15:21:22 -04:00
parent 2ffa6202f1
commit 9409f4bb08
11 changed files with 245 additions and 86 deletions

View File

@@ -10,7 +10,7 @@
PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=0.9.14
JAMA_VERSION=0.9.15
# App port — the host port Docker maps to the container
PORT=3000

View File

@@ -1,6 +1,6 @@
{
"name": "jama-backend",
"version": "0.9.14",
"version": "0.9.15",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -169,6 +169,9 @@ function initDb() {
insertSetting.run('icon_groupinfo', '');
insertSetting.run('pwa_icon_192', '');
insertSetting.run('pwa_icon_512', '');
insertSetting.run('color_title', '');
insertSetting.run('color_avatar_public', '');
insertSetting.run('color_avatar_dm', '');
// Migration: add hide_admin_tag if upgrading from older version
try {

View File

@@ -114,12 +114,22 @@ router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.
});
// Reset all settings to defaults (admin)
router.patch('/colors', authMiddleware, adminMiddleware, (req, res) => {
const { colorTitle, colorAvatarPublic, colorAvatarDm } = req.body;
const db = getDb();
const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
if (colorTitle !== undefined) upd.run('color_title', colorTitle || '', colorTitle || '');
if (colorAvatarPublic !== undefined) upd.run('color_avatar_public', colorAvatarPublic || '', colorAvatarPublic || '');
if (colorAvatarDm !== undefined) upd.run('color_avatar_dm', colorAvatarDm || '', colorAvatarDm || '');
res.json({ success: true });
});
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const originalName = process.env.APP_NAME || 'jama';
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512')").run();
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512', 'color_title', 'color_avatar_public', 'color_avatar_dm')").run();
res.json({ success: true });
});

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.9.14}"
VERSION="${1:-0.9.15}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.9.14",
"version": "0.9.15",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -2,22 +2,50 @@ import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
const DEFAULT_TITLE_COLOR = '#1a73e8';
const DEFAULT_PUBLIC_COLOR = '#1a73e8';
const DEFAULT_DM_COLOR = '#a142f4';
function ColorSwatch({ color, title }) {
return (
<div style={{
width: 32, height: 32, borderRadius: 8,
background: color,
border: '2px solid var(--border)',
flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
}} title={title} />
);
}
export default function BrandingModal({ onClose }) {
const toast = useToast();
const [tab, setTab] = useState('general'); // 'general' | 'colors'
const [settings, setSettings] = useState({});
const [appName, setAppName] = useState('');
const [loading, setLoading] = useState(false);
const [resetting, setResetting] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
// Color state
const [colorTitle, setColorTitle] = useState(DEFAULT_TITLE_COLOR);
const [colorPublic, setColorPublic] = useState(DEFAULT_PUBLIC_COLOR);
const [colorDm, setColorDm] = useState(DEFAULT_DM_COLOR);
const [savingColors, setSavingColors] = useState(false);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setSettings(settings);
setAppName(settings.app_name || 'jama');
setColorTitle(settings.color_title || DEFAULT_TITLE_COLOR);
setColorPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
setColorDm(settings.color_avatar_dm || DEFAULT_DM_COLOR);
}).catch(() => {});
}, []);
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
const notifySidebarRefresh = () => {
window.dispatchEvent(new Event('jama:settings-changed'));
};
const handleSaveName = async () => {
if (!appName.trim()) return;
@@ -48,6 +76,29 @@ export default function BrandingModal({ onClose }) {
}
};
const handleSaveColors = async () => {
setSavingColors(true);
try {
await api.updateColors({
colorTitle,
colorAvatarPublic: colorPublic,
colorAvatarDm: colorDm,
});
setSettings(prev => ({
...prev,
color_title: colorTitle,
color_avatar_public: colorPublic,
color_avatar_dm: colorDm,
}));
toast('Colors updated', 'success');
notifySidebarRefresh();
} catch (e) {
toast(e.message, 'error');
} finally {
setSavingColors(false);
}
};
const handleReset = async () => {
setResetting(true);
try {
@@ -55,6 +106,9 @@ export default function BrandingModal({ onClose }) {
const { settings: fresh } = await api.getSettings();
setSettings(fresh);
setAppName(fresh.app_name || 'jama');
setColorTitle(DEFAULT_TITLE_COLOR);
setColorPublic(DEFAULT_PUBLIC_COLOR);
setColorDm(DEFAULT_DM_COLOR);
toast('Settings reset to defaults', 'success');
notifySidebarRefresh();
setShowResetConfirm(false);
@@ -75,6 +129,14 @@ export default function BrandingModal({ onClose }) {
</button>
</div>
{/* Tabs */}
<div className="flex gap-2" style={{ marginBottom: 24 }}>
<button className={`btn btn-sm ${tab === 'general' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('general')}>General</button>
<button className={`btn btn-sm ${tab === 'colors' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('colors')}>Colors</button>
</div>
{tab === 'general' && (
<>
{/* App Logo */}
<div style={{ marginBottom: 24 }}>
<div className="settings-section-label">App Logo</div>
@@ -127,7 +189,7 @@ export default function BrandingModal({ onClose }) {
borderRadius: 'var(--radius)', padding: '12px 14px'
}}>
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
This will reset the app name and logo to their install defaults. This cannot be undone.
This will reset the app name, logo and all colours to their install defaults. This cannot be undone.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
@@ -151,6 +213,81 @@ export default function BrandingModal({ onClose }) {
<span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
</div>
)}
</>
)}
{tab === 'colors' && (
<div className="flex-col gap-3">
{/* App Title Color */}
<div>
<div className="settings-section-label">App Title Color</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 10 }}>
The color of the app name shown in the top bar.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="color"
value={colorTitle}
onChange={e => setColorTitle(e.target.value)}
style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }}
/>
<ColorSwatch color={colorTitle} title="Title color preview" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{colorTitle}</span>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={() => setColorTitle(DEFAULT_TITLE_COLOR)}>Reset</button>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<div className="settings-section-label">Public Message Avatar Color</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 10 }}>
Background color for public channel avatars (users without a custom avatar).
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="color"
value={colorPublic}
onChange={e => setColorPublic(e.target.value)}
style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }}
/>
<div style={{
width: 36, height: 36, borderRadius: '50%', background: colorPublic,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
}}>A</div>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{colorPublic}</span>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={() => setColorPublic(DEFAULT_PUBLIC_COLOR)}>Reset</button>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<div className="settings-section-label">Direct Message Avatar Color</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 10 }}>
Background color for private group and direct message avatars (users without a custom avatar).
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="color"
value={colorDm}
onChange={e => setColorDm(e.target.value)}
style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }}
/>
<div style={{
width: 36, height: 36, borderRadius: '50%', background: colorDm,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
}}>B</div>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{colorDm}</span>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={() => setColorDm(DEFAULT_DM_COLOR)}>Reset</button>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<button className="btn btn-primary" onClick={handleSaveColors} disabled={savingColors}>
{savingColors ? 'Saving...' : 'Save Colors'}
</button>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -18,6 +18,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
const [hasMore, setHasMore] = useState(false);
const [typing, setTyping] = useState([]);
const [iconGroupInfo, setIconGroupInfo] = useState('');
const [avatarColors, setAvatarColors] = useState({ public: '#1a73e8', dm: '#a142f4' });
const [showInfo, setShowInfo] = useState(false);
const [replyTo, setReplyTo] = useState(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
@@ -33,14 +34,20 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
}, []);
useEffect(() => {
api.getSettings()
.then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || ''))
.catch(() => {});
const handler = () => api.getSettings()
.then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || ''))
.catch(() => {});
api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || '');
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
}).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || '');
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
}).catch(() => {});
window.addEventListener('jama:settings-updated', handler);
return () => window.removeEventListener('jama:settings-updated', handler);
window.addEventListener('jama:settings-changed', handler);
return () => {
window.removeEventListener('jama:settings-updated', handler);
window.removeEventListener('jama:settings-changed', handler);
};
}, []);
const scrollToBottom = useCallback((smooth = false) => {
@@ -224,7 +231,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
{isOnline && <span className="online-dot" style={{ position: 'absolute', bottom: 1, right: 1 }} />}
</div>
) : (
<div className="group-icon-sm" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4', flexShrink: 0 }}>
<div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
{group.type === 'public' ? '#' : isDirect ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
</div>
)}

View File

@@ -15,6 +15,7 @@ export default function GlobalBar({ isMobile, showSidebar }) {
const appName = settings.app_name || 'jama';
const logoUrl = settings.logo_url;
const titleColor = settings.color_title || null;
// On mobile: show bar only when sidebar is visible (chat list view)
// On desktop: always show
@@ -28,7 +29,7 @@ export default function GlobalBar({ isMobile, showSidebar }) {
alt={appName}
className="global-bar-logo"
/>
<span className="global-bar-title">{appName}</span>
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
</div>
{!connected && (
<span className="global-bar-offline" title="Offline">

View File

@@ -16,7 +16,7 @@ function useTheme() {
}
function useAppSettings() {
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
const fetchSettings = () => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
};
@@ -110,7 +110,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{group.is_direct && group.peer_avatar ? (
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} />
) : (
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}>
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
</div>
)}

View File

@@ -100,6 +100,7 @@ export const api = {
// Settings
getSettings: () => req('GET', '/settings'),
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
updateColors: (body) => req('PATCH', '/settings/colors', body),
uploadLogo: (file) => {
const form = new FormData(); form.append('logo', file);
return req('POST', '/settings/logo', form);