import { useState, useEffect, useRef, useCallback } from 'react'; import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; const DEFAULT_TITLE_COLOR = '#1a73e8'; // light mode default const DEFAULT_TITLE_DARK_COLOR = '#60a5fa'; // dark mode default (lighter blue readable on dark bg) const DEFAULT_PUBLIC_COLOR = '#1a73e8'; const DEFAULT_DM_COLOR = '#a142f4'; const COLOUR_SUGGESTIONS = [ '#1a73e8', '#a142f4', '#e53935', '#fa7b17', '#fdd835', '#34a853', ]; // ── Title Colour Row — one row per mode ────────────────────────────────────── function TitleColourRow({ bgColor, bgLabel, textColor, onChange }) { const [mode, setMode] = useState('idle'); // 'idle' | 'custom' return (
{/* Preview box */}
Title
{mode === 'idle' && ( <> {textColor} )} {mode === 'custom' && (
{ onChange(hex); setMode('idle'); }} onBack={() => setMode('idle')} />
)}
); } // ── Colour math helpers ────────────────────────────────────────────────────── function hexToHsv(hex) { const r = parseInt(hex.slice(1,3),16)/255; const g = parseInt(hex.slice(3,5),16)/255; const b = parseInt(hex.slice(5,7),16)/255; const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min; let h = 0; if (d !== 0) { if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; else if (max === g) h = ((b - r) / d + 2) / 6; else h = ((r - g) / d + 4) / 6; } return { h: h * 360, s: max === 0 ? 0 : d / max, v: max }; } function hsvToHex(h, s, v) { h = h / 360; const i = Math.floor(h * 6); const f = h * 6 - i; const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); let r, g, b; switch (i % 6) { case 0: r=v; g=t; b=p; break; case 1: r=q; g=v; b=p; break; case 2: r=p; g=v; b=t; break; case 3: r=p; g=q; b=v; break; case 4: r=t; g=p; b=v; break; default: r=v; g=p; b=q; } return '#' + [r,g,b].map(x => Math.round(x*255).toString(16).padStart(2,'0')).join(''); } function isValidHex(h) { return /^#[0-9a-fA-F]{6}$/.test(h); } // ── SV (saturation/value) square ───────────────────────────────────────────── function SvSquare({ hue, s, v, onChange }) { const canvasRef = useRef(null); const dragging = useRef(false); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; // White → hue gradient (left→right) const hGrad = ctx.createLinearGradient(0, 0, W, 0); hGrad.addColorStop(0, '#fff'); hGrad.addColorStop(1, `hsl(${hue},100%,50%)`); ctx.fillStyle = hGrad; ctx.fillRect(0, 0, W, H); // Transparent → black gradient (top→bottom) const vGrad = ctx.createLinearGradient(0, 0, 0, H); vGrad.addColorStop(0, 'transparent'); vGrad.addColorStop(1, '#000'); ctx.fillStyle = vGrad; ctx.fillRect(0, 0, W, H); }, [hue]); const getPos = (e, canvas) => { const r = canvas.getBoundingClientRect(); const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left; const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top; return { s: Math.max(0, Math.min(1, cx / r.width)), v: Math.max(0, Math.min(1, 1 - cy / r.height)), }; }; const handle = (e) => { e.preventDefault(); const p = getPos(e, canvasRef.current); onChange(p.s, p.v); }; return (
{ dragging.current = true; handle(e); }} onMouseMove={e => { if (dragging.current) handle(e); }} onMouseUp={() => { dragging.current = false; }} onMouseLeave={() => { dragging.current = false; }} onTouchStart={handle} onTouchMove={handle} /> {/* Cursor circle */}
); } // ── Hue bar ─────────────────────────────────────────────────────────────────── function HueBar({ hue, onChange }) { const barRef = useRef(null); const dragging = useRef(false); const handle = (e) => { e.preventDefault(); const r = barRef.current.getBoundingClientRect(); const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left; onChange(Math.max(0, Math.min(360, (cx / r.width) * 360))); }; return (
{ dragging.current = true; handle(e); }} onMouseMove={e => { if (dragging.current) handle(e); }} onMouseUp={() => { dragging.current = false; }} onMouseLeave={() => { dragging.current = false; }} onTouchStart={handle} onTouchMove={handle} />
); } // ── Custom HSV picker ───────────────────────────────────────────────────────── function CustomPicker({ initial, onSet, onBack }) { const { h: ih, s: is, v: iv } = hexToHsv(initial); const [hue, setHue] = useState(ih); const [sat, setSat] = useState(is); const [val, setVal] = useState(iv); const [hexInput, setHexInput] = useState(initial); const [hexError, setHexError] = useState(false); const current = hsvToHex(hue, sat, val); // Sync hex input when sliders change useEffect(() => { setHexInput(current); setHexError(false); }, [current]); const handleHexInput = (e) => { const v = e.target.value; setHexInput(v); if (isValidHex(v)) { const { h, s, v: bv } = hexToHsv(v); setHue(h); setSat(s); setVal(bv); setHexError(false); } else { setHexError(true); } }; return (
{ setSat(s); setVal(v); }} /> {/* Preview + hex input */}
Chosen colour
{/* Actions */}
); } // ── ColourPicker card ───────────────────────────────────────────────────────── function ColourPicker({ label, value, onChange, preview }) { const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom' return (
{label}
{/* Current colour preview */}
{preview ? preview(value) :
} {value}
{mode === 'suggestions' && ( <>
{COLOUR_SUGGESTIONS.map(hex => (
)} {mode === 'custom' && ( { onChange(hex); setMode('suggestions'); }} onBack={() => setMode('suggestions')} /> )}
); } export default function BrandingModal({ onClose }) { const toast = useToast(); const [tab, setTab] = useState('general'); // 'general' | 'colours' const [settings, setSettings] = useState({}); const [appName, setAppName] = useState(''); const [loading, setLoading] = useState(false); const [resetting, setResetting] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); const [colourTitle, setColourTitle] = useState(DEFAULT_TITLE_COLOR); const [colourTitleDark, setColourTitleDark] = useState(DEFAULT_TITLE_DARK_COLOR); const [colourPublic, setColourPublic] = useState(DEFAULT_PUBLIC_COLOR); const [colourDm, setColourDm] = useState(DEFAULT_DM_COLOR); const [savingColours, setSavingColours] = useState(false); useEffect(() => { api.getSettings().then(({ settings }) => { setSettings(settings); setAppName(settings.app_name || 'rosterchirp'); setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR); setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR); setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR); setColourDm(settings.color_avatar_dm || DEFAULT_DM_COLOR); }).catch(() => {}); }, []); const notifySidebarRefresh = () => window.dispatchEvent(new Event('rosterchirp:settings-changed')); const handleSaveName = async () => { if (!appName.trim()) return; setLoading(true); try { await api.updateAppName(appName.trim()); setSettings(prev => ({ ...prev, app_name: appName.trim() })); toast('App name updated', 'success'); notifySidebarRefresh(); } catch (e) { toast(e.message, 'error'); } finally { setLoading(false); } }; const handleLogoUpload = async (e) => { const file = e.target.files?.[0]; if (!file) return; if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error'); try { const { logoUrl } = await api.uploadLogo(file); setSettings(prev => ({ ...prev, logo_url: logoUrl })); toast('Logo updated', 'success'); notifySidebarRefresh(); } catch (e) { toast(e.message, 'error'); } }; const handleSaveColours = async () => { setSavingColours(true); try { await api.updateColors({ colorTitle: colourTitle, colorTitleDark: colourTitleDark, colorAvatarPublic: colourPublic, colorAvatarDm: colourDm, }); setSettings(prev => ({ ...prev, color_title: colourTitle, color_title_dark: colourTitleDark, color_avatar_public: colourPublic, color_avatar_dm: colourDm, })); toast('Colours updated', 'success'); notifySidebarRefresh(); } catch (e) { toast(e.message, 'error'); } finally { setSavingColours(false); } }; const handleReset = async () => { setResetting(true); try { await api.resetSettings(); const { settings: fresh } = await api.getSettings(); setSettings(fresh); setAppName(fresh.app_name || 'rosterchirp'); setColourTitle(DEFAULT_TITLE_COLOR); setColourTitleDark(DEFAULT_TITLE_DARK_COLOR); setColourPublic(DEFAULT_PUBLIC_COLOR); setColourDm(DEFAULT_DM_COLOR); toast('Settings reset to defaults', 'success'); notifySidebarRefresh(); setShowResetConfirm(false); } catch (e) { toast(e.message, 'error'); } finally { setResetting(false); } }; return (
e.target === e.currentTarget && onClose()}>

Branding

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

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

{/* App Name */}
App Name
setAppName(e.target.value)} autoComplete="new-password" onKeyDown={e => e.key === 'Enter' && handleSaveName()} />

Maximum 16 characters including spaces. Currently {appName.length}/16.

{/* Reset */}
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 === 'colours' && (
App Title Colour
(
A
)} />
(
B
)} />
)}
); }