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 PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest') # 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 # App port — the host port Docker maps to the container
PORT=3000 PORT=3000

View File

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

View File

@@ -169,6 +169,9 @@ function initDb() {
insertSetting.run('icon_groupinfo', ''); insertSetting.run('icon_groupinfo', '');
insertSetting.run('pwa_icon_192', ''); insertSetting.run('pwa_icon_192', '');
insertSetting.run('pwa_icon_512', ''); 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 // Migration: add hide_admin_tag if upgrading from older version
try { try {

View File

@@ -114,12 +114,22 @@ router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.
}); });
// Reset all settings to defaults (admin) // 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) => { router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
const originalName = process.env.APP_NAME || 'jama'; 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 = '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 = '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 }); res.json({ success: true });
}); });

View File

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

View File

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

View File

@@ -2,22 +2,50 @@ import { useState, useEffect } from 'react';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx'; 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 }) { export default function BrandingModal({ onClose }) {
const toast = useToast(); const toast = useToast();
const [tab, setTab] = useState('general'); // 'general' | 'colors'
const [settings, setSettings] = useState({}); const [settings, setSettings] = useState({});
const [appName, setAppName] = useState(''); const [appName, setAppName] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [showResetConfirm, setShowResetConfirm] = 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(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
setSettings(settings); setSettings(settings);
setAppName(settings.app_name || 'jama'); 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(() => {}); }).catch(() => {});
}, []); }, []);
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed')); const notifySidebarRefresh = () => {
window.dispatchEvent(new Event('jama:settings-changed'));
};
const handleSaveName = async () => { const handleSaveName = async () => {
if (!appName.trim()) return; 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 () => { const handleReset = async () => {
setResetting(true); setResetting(true);
try { try {
@@ -55,6 +106,9 @@ export default function BrandingModal({ onClose }) {
const { settings: fresh } = await api.getSettings(); const { settings: fresh } = await api.getSettings();
setSettings(fresh); setSettings(fresh);
setAppName(fresh.app_name || 'jama'); setAppName(fresh.app_name || 'jama');
setColorTitle(DEFAULT_TITLE_COLOR);
setColorPublic(DEFAULT_PUBLIC_COLOR);
setColorDm(DEFAULT_DM_COLOR);
toast('Settings reset to defaults', 'success'); toast('Settings reset to defaults', 'success');
notifySidebarRefresh(); notifySidebarRefresh();
setShowResetConfirm(false); setShowResetConfirm(false);
@@ -75,6 +129,14 @@ export default function BrandingModal({ onClose }) {
</button> </button>
</div> </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 */} {/* App Logo */}
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div className="settings-section-label">App Logo</div> <div className="settings-section-label">App Logo</div>
@@ -127,7 +189,7 @@ export default function BrandingModal({ onClose }) {
borderRadius: 'var(--radius)', padding: '12px 14px' borderRadius: 'var(--radius)', padding: '12px 14px'
}}> }}>
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}> <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> </p>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}> <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> <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> </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>
</div> </div>
); );

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ function useTheme() {
} }
function useAppSettings() { 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 = () => { const fetchSettings = () => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); 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 ? ( {group.is_direct && group.peer_avatar ? (
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} /> <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()} {group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
</div> </div>
)} )}

View File

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