v0.9.15 updated branding modal
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.15",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
2
build.sh
2
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user