diff --git a/.env.example b/.env.example index 15c04a1..d9c2521 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/package.json b/backend/package.json index 8fd063f..6995f75 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.14", + "version": "0.9.15", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 6f3a37c..ecac3f0 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -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 { diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index c8749a0..c081b05 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -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 }); }); diff --git a/build.sh b/build.sh index bc90722..f7fe532 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.14}" +VERSION="${1:-0.9.15}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 9165d47..5d9313f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.14", + "version": "0.9.15", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/BrandingModal.jsx b/frontend/src/components/BrandingModal.jsx index 63436d5..caee937 100644 --- a/frontend/src/components/BrandingModal.jsx +++ b/frontend/src/components/BrandingModal.jsx @@ -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 ( +
+ ); +} + 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,80 +129,163 @@ export default function BrandingModal({ onClose }) {
- {/* App Logo */} -
-
App Logo
-
-
- logo -
-
- -

- Square format, max 1MB. Used in sidebar, login page and browser tab. -

-
-
+ {/* Tabs */} +
+ +
- {/* App Name */} -
-
App Name
-
- setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} /> - -
-
- - {/* Reset + Version */} -
-
Reset
-
- {!showResetConfirm ? ( - - ) : ( -
-

- This will reset the app name and logo to their install defaults. This cannot be undone. -

-
- - + {tab === 'general' && ( + <> + {/* App Logo */} +
+
App Logo
+
+
+ logo +
+
+ +

+ Square format, max 1MB. Used in sidebar, login page and browser tab. +

- )} - {settings.app_version && ( - - v{settings.app_version} - - )} -
-
+
- {settings.pw_reset_active === 'true' && ( -
- ⚠️ - ADMPW_RESET is active. The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this. + {/* App Name */} +
+
App Name
+
+ setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} /> + +
+
+ + {/* Reset + Version */} +
+
Reset
+
+ {!showResetConfirm ? ( + + ) : ( +
+

+ This will reset the app name, logo and all colours to their install defaults. This cannot be undone. +

+
+ + +
+
+ )} + {settings.app_version && ( + + v{settings.app_version} + + )} +
+
+ + {settings.pw_reset_active === 'true' && ( +
+ ⚠️ + ADMPW_RESET is active. The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this. +
+ )} + + )} + + {tab === 'colors' && ( +
+ {/* App Title Color */} +
+
App Title Color
+

+ The color of the app name shown in the top bar. +

+
+ setColorTitle(e.target.value)} + style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }} + /> + + {colorTitle} + +
+
+ +
+
Public Message Avatar Color
+

+ Background color for public channel avatars (users without a custom avatar). +

+
+ setColorPublic(e.target.value)} + style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }} + /> +
A
+ {colorPublic} + +
+
+ +
+
Direct Message Avatar Color
+

+ Background color for private group and direct message avatars (users without a custom avatar). +

+
+ setColorDm(e.target.value)} + style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }} + /> +
B
+ {colorDm} + +
+
+ +
+ +
)}
diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 0bcbd5d..8fc3745 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -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 && }
) : ( -
+
{group.type === 'public' ? '#' : isDirect ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
)} diff --git a/frontend/src/components/GlobalBar.jsx b/frontend/src/components/GlobalBar.jsx index 1ffe19c..ed3f9af 100644 --- a/frontend/src/components/GlobalBar.jsx +++ b/frontend/src/components/GlobalBar.jsx @@ -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" /> - {appName} + {appName}
{!connected && ( diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 1c34ffc..e271400 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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 ? ( {group.name} ) : ( -
+
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
)} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index f7520e3..d8ba50b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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);