v0.9.16 updated baranding colours

This commit is contained in:
2026-03-14 15:58:04 -04:00
parent 9409f4bb08
commit 313095984f
5 changed files with 160 additions and 119 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.15 JAMA_VERSION=0.9.16
# 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.15", "version": "0.9.16",
"description": "TeamChat backend server", "description": "TeamChat backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.9.15}" VERSION="${1:-0.9.16}"
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.15", "version": "0.9.16",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } 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';
@@ -6,46 +6,124 @@ const DEFAULT_TITLE_COLOR = '#1a73e8';
const DEFAULT_PUBLIC_COLOR = '#1a73e8'; const DEFAULT_PUBLIC_COLOR = '#1a73e8';
const DEFAULT_DM_COLOR = '#a142f4'; const DEFAULT_DM_COLOR = '#a142f4';
function ColorSwatch({ color, title }) { const COLOUR_SUGGESTIONS = [
'#1a73e8','#a142f4','#e53935','#34a853','#fa7b17',
'#00897b','#e91e8c','#0097a7','#5c6bc0','#f4511e',
'#616161','#795548','#00acc1','#43a047','#fdd835',
];
// A single colour picker card: shows suggestions by default, Custom button opens native picker
function ColourPicker({ label, description, value, onChange, preview }) {
const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom'
const inputRef = useRef(null);
const handleCustomClick = () => {
setMode('custom');
// Open native colour picker immediately after switching
setTimeout(() => inputRef.current?.click(), 50);
};
return ( return (
<div style={{ <div>
width: 32, height: 32, borderRadius: 8, <div className="settings-section-label">{label}</div>
background: color, {description && (
border: '2px solid var(--border)', <p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>{description}</p>
flexShrink: 0, )}
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
}} title={title} /> {/* Current colour preview */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
{preview
? preview(value)
: <div style={{ width: 36, height: 36, borderRadius: 8, background: value, border: '2px solid var(--border)', flexShrink: 0 }} />
}
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{value}</span>
</div>
{mode === 'suggestions' && (
<>
{/* Suggestion swatches */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
{COLOUR_SUGGESTIONS.map(hex => (
<button
key={hex}
onClick={() => onChange(hex)}
style={{
width: 32, height: 32, borderRadius: 8,
background: hex, border: hex === value ? '3px solid var(--text-primary)' : '2px solid var(--border)',
cursor: 'pointer', flexShrink: 0,
boxShadow: hex === value ? '0 0 0 2px var(--surface), 0 0 0 4px var(--text-primary)' : 'none',
transition: 'box-shadow 0.15s',
}}
title={hex}
/>
))}
</div>
{/* Custom button — styled to match suggestion swatches row */}
<button
className="btn btn-secondary btn-sm"
onClick={handleCustomClick}
>
Custom
</button>
</>
)}
{mode === 'custom' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
ref={inputRef}
type="color"
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: 56, height: 44, padding: 2,
borderRadius: 8, border: '1px solid var(--border)',
cursor: 'pointer', background: 'none',
}}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{value}</span>
</div>
{/* Back button — same style as Custom button */}
<div>
<button
className="btn btn-secondary btn-sm"
onClick={() => setMode('suggestions')}
>
Back
</button>
</div>
</div>
)}
</div>
); );
} }
export default function BrandingModal({ onClose }) { export default function BrandingModal({ onClose }) {
const toast = useToast(); const toast = useToast();
const [tab, setTab] = useState('general'); // 'general' | 'colors' const [tab, setTab] = useState('general'); // 'general' | 'colours'
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 [colourTitle, setColourTitle] = useState(DEFAULT_TITLE_COLOR);
const [colorTitle, setColorTitle] = useState(DEFAULT_TITLE_COLOR); const [colourPublic, setColourPublic] = useState(DEFAULT_PUBLIC_COLOR);
const [colorPublic, setColorPublic] = useState(DEFAULT_PUBLIC_COLOR); const [colourDm, setColourDm] = useState(DEFAULT_DM_COLOR);
const [colorDm, setColorDm] = useState(DEFAULT_DM_COLOR); const [savingColours, setSavingColours] = useState(false);
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); setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
setColorPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR); setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
setColorDm(settings.color_avatar_dm || DEFAULT_DM_COLOR); setColourDm(settings.color_avatar_dm || DEFAULT_DM_COLOR);
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
const notifySidebarRefresh = () => { const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
window.dispatchEvent(new Event('jama:settings-changed'));
};
const handleSaveName = async () => { const handleSaveName = async () => {
if (!appName.trim()) return; if (!appName.trim()) return;
@@ -76,26 +154,26 @@ export default function BrandingModal({ onClose }) {
} }
}; };
const handleSaveColors = async () => { const handleSaveColours = async () => {
setSavingColors(true); setSavingColours(true);
try { try {
await api.updateColors({ await api.updateColors({
colorTitle, colorTitle: colourTitle,
colorAvatarPublic: colorPublic, colorAvatarPublic: colourPublic,
colorAvatarDm: colorDm, colorAvatarDm: colourDm,
}); });
setSettings(prev => ({ setSettings(prev => ({
...prev, ...prev,
color_title: colorTitle, color_title: colourTitle,
color_avatar_public: colorPublic, color_avatar_public: colourPublic,
color_avatar_dm: colorDm, color_avatar_dm: colourDm,
})); }));
toast('Colors updated', 'success'); toast('Colours updated', 'success');
notifySidebarRefresh(); notifySidebarRefresh();
} catch (e) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
} finally { } finally {
setSavingColors(false); setSavingColours(false);
} }
}; };
@@ -106,9 +184,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); setColourTitle(DEFAULT_TITLE_COLOR);
setColorPublic(DEFAULT_PUBLIC_COLOR); setColourPublic(DEFAULT_PUBLIC_COLOR);
setColorDm(DEFAULT_DM_COLOR); setColourDm(DEFAULT_DM_COLOR);
toast('Settings reset to defaults', 'success'); toast('Settings reset to defaults', 'success');
notifySidebarRefresh(); notifySidebarRefresh();
setShowResetConfirm(false); setShowResetConfirm(false);
@@ -132,7 +210,7 @@ export default function BrandingModal({ onClose }) {
{/* Tabs */} {/* Tabs */}
<div className="flex gap-2" style={{ marginBottom: 24 }}> <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 === '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> <button className={`btn btn-sm ${tab === 'colours' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('colours')}>Colours</button>
</div> </div>
{tab === 'general' && ( {tab === 'general' && (
@@ -146,11 +224,7 @@ export default function BrandingModal({ onClose }) {
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex', border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0 alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}> }}>
<img <img src={settings.logo_url || '/icons/jama.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
src={settings.logo_url || '/icons/jama.png'}
alt="logo"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div> </div>
<div> <div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}> <label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
@@ -169,25 +243,18 @@ export default function BrandingModal({ onClose }) {
<div className="settings-section-label">App Name</div> <div className="settings-section-label">App Name</div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} /> <input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}> <button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>{loading ? '...' : 'Save'}</button>
{loading ? '...' : 'Save'}
</button>
</div> </div>
</div> </div>
{/* Reset + Version */} {/* Reset */}
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}> <div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
<div className="settings-section-label">Reset</div> <div className="settings-section-label">Reset</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
{!showResetConfirm ? ( {!showResetConfirm ? (
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}> <button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>Reset All to Defaults</button>
Reset All to Defaults
</button>
) : ( ) : (
<div style={{ <div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '12px 14px' }}>
background: '#fce8e6', border: '1px solid #f5c6c2',
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, logo and all colours 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>
@@ -200,9 +267,7 @@ export default function BrandingModal({ onClose }) {
</div> </div>
)} )}
{settings.app_version && ( {settings.app_version && (
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}> <span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>v{settings.app_version}</span>
v{settings.app_version}
</span>
)} )}
</div> </div>
</div> </div>
@@ -216,74 +281,50 @@ export default function BrandingModal({ onClose }) {
</> </>
)} )}
{tab === 'colors' && ( {tab === 'colours' && (
<div className="flex-col gap-3"> <div className="flex-col gap-3">
{/* App Title Color */} <ColourPicker
<div> label="App Title Colour"
<div className="settings-section-label">App Title Color</div> description="The colour of the app name shown in the top bar."
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 10 }}> value={colourTitle}
The color of the app name shown in the top bar. onChange={setColourTitle}
</p> />
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input <div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
type="color" <ColourPicker
value={colorTitle} label="Public Message Avatar Colour"
onChange={e => setColorTitle(e.target.value)} description="Background colour for public channel icons (users without a custom avatar)."
style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }} value={colourPublic}
/> onChange={setColourPublic}
<ColorSwatch color={colorTitle} title="Title color preview" /> preview={(val) => (
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{colorTitle}</span> <div style={{
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={() => setColorTitle(DEFAULT_TITLE_COLOR)}>Reset</button> width: 36, height: 36, borderRadius: '50%', background: val,
</div> display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
}}>A</div>
)}
/>
</div> </div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}> <div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<div className="settings-section-label">Public Message Avatar Color</div> <ColourPicker
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 10 }}> label="Direct Message Avatar Colour"
Background color for public channel avatars (users without a custom avatar). description="Background colour for private group and direct message icons (users without a custom avatar)."
</p> value={colourDm}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> onChange={setColourDm}
<input preview={(val) => (
type="color" <div style={{
value={colorPublic} width: 36, height: 36, borderRadius: '50%', background: val,
onChange={e => setColorPublic(e.target.value)} display: 'flex', alignItems: 'center', justifyContent: 'center',
style={{ width: 48, height: 40, padding: 2, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer', background: 'none' }} color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
/> }}>B</div>
<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>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}> <div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<div className="settings-section-label">Direct Message Avatar Color</div> <button className="btn btn-primary" onClick={handleSaveColours} disabled={savingColours}>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 10 }}> {savingColours ? 'Saving...' : 'Save Colours'}
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> </button>
</div> </div>
</div> </div>