import { useEffect, useMemo, useState } from 'react' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { api } from '../../../api/client.js' import { useSite } from '../../../contexts/SiteContext.jsx' import { parseJsonSetting } from '../../../lib/settingsJson.js' import BrandAssetsPanel from './BrandAssetsPanel.jsx' // Admin · Appearance — the theme and brand-asset halves of // docs/website/THEMING_AND_NAV.md (phases 3-5). The nav builder is phase 7 and // gets its own screen. // // Two things shape this form: // // • Every control is a closed set. The presets, the font shortlist and the // shadow depths all come from GET /settings/theme/options, which is derived // from the same server config the save is validated against — so the form // can never offer a value the server would reject. Nothing here is free // text except the color inputs, which are and so are // hex by construction. // • Saving means writing a settings row; resetting means DELETING it. Absence // of the row is what selects the shipped default, so "reset" cannot write a // copy of the defaults — see §2. // Human labels for the eight editable colors and four radii. The field names // and the CSS variables they drive both come from the server // (colorFields / radiusFields); this only decorates them, and a field with no // label here still renders under its raw name rather than vanishing. const COLOR_LABELS = { bg: 'Background', bgDeep: 'Background (deep)', panelA: 'Panel (top)', panelB: 'Panel (bottom)', accent: 'Accent', accentBright: 'Accent (bright)', ink: 'Ink / headings', text: 'Body text', } const RADIUS_LABELS = { radiusPill: 'Pills & buttons', radiusPanel: 'Flat panels', radiusCard: 'Cards & panels', radiusInput: 'Inputs & notes', } const FONT_LABELS = { serif: 'Body serif', display: 'Display / headings', sans: 'Interface sans', } // Strip empty groups so a theme the admin cleared back out is stored as a bare // preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a // field out to "clear" it — remove it (§6.1). function compactCustom(custom) { const out = {} for (const [group, fields] of Object.entries(custom)) { const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null)) if (Object.keys(kept).length) out[group] = kept } return Object.keys(out).length ? out : null } export default function AppearanceAdmin() { const { refresh: refreshSite } = useSite() const [options, setOptions] = useState(null) const [preset, setPreset] = useState('runic-gateway') const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} }) // Whether a theme_visual row exists at all. Drives the "reset" button and the // "this instance is using the shipped theme" note — an admin needs to be able // to tell "never themed" from "themed to look like the default". const [stored, setStored] = useState(false) // The brand-asset overrides, read in the same settings fetch and then owned by // the panel below (its uploads save on their own, so it does not share this // screen's Save button). const [assets, setAssets] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [busy, setBusy] = useState(false) const [saved, setSaved] = useState(false) useEffect(() => { let active = true Promise.all([api.themeOptions(), api.admin.getSettings()]) .then(([opts, all]) => { if (!active) return setOptions(opts) // The stored values are JSON strings (settings.value is TEXT), and a // malformed one reads as absent exactly as the server treats it — the // form then shows the shipped default rather than an error. const parsed = parseJsonSetting(all.theme_visual) setStored(Boolean(all.theme_visual)) setAssets(parseJsonSetting(all.brand_assets) || {}) if (parsed) { setPreset(parsed.preset || 'runic-gateway') setCustom({ colors: parsed.custom?.colors || {}, fonts: parsed.custom?.fonts || {}, structure: parsed.custom?.structure || {}, }) } }) .catch(() => active && setError('Could not load the appearance settings.')) .finally(() => active && setLoading(false)) return () => { active = false } }, []) // What an unset field currently resolves to: the selected preset's palette, // or the shipped theme when the preset is Custom (which has no base). Lets a // color picker open on the value the admin is actually looking at. const baseTokens = useMemo(() => { if (!options) return {} return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens }, [options, preset]) if (loading) return if (error && !options) return const setField = (group, field) => (value) => { setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } })) setSaved(false) } const clearField = (group, field) => () => { setCustom((c) => { const next = { ...c[group] } delete next[field] return { ...c, [group]: next } }) setSaved(false) } async function save() { setBusy(true) setError('') try { await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } }) setStored(true) setSaved(true) // Repull the public settings so the surrounding admin UI re-themes itself // immediately — the admin sees the change they just made. await refreshSite() } catch (err) { setError(err.message || 'Could not save the theme.') } finally { setBusy(false) } } async function resetAll() { setBusy(true) setError('') try { await api.admin.resetSetting('theme_visual') setPreset('runic-gateway') setCustom({ colors: {}, fonts: {}, structure: {} }) setStored(false) setSaved(false) await refreshSite() } catch (err) { setError(err.message || 'Could not reset the theme.') } finally { setBusy(false) } } return (

Colors, fonts and corner radius for the public site, this admin panel and the player portal. {' '} {stored ? ( <>This instance has a saved theme. Reset to default deletes it and returns to the shipped look. ) : ( <>This instance has never been themed, so it uses the shipped look and its BRAND_* accent. )}

{/* ── Preset ─────────────────────────────────────────────── */}
Preset
{options.presets.map((p) => ( ))}
{preset === 'custom' ? 'Custom starts from the shipped theme — only the fields you set below change.' : 'A preset sets the whole palette. Anything you set below overrides it, field by field.'}
{/* ── Colors ─────────────────────────────────────────────── */}
Colors
{options.colorFields.map(({ name, token }) => { const set = custom.colors[name] !== undefined return (
{/* has no empty state, so an unset field shows what it currently resolves to rather than black. */} setField('colors', name)(e.target.value)} aria-label={COLOR_LABELS[name] || name} style={{ width: 34, height: 30, padding: 0, border: '1px solid var(--line)', borderRadius: 6, background: 'transparent', cursor: 'pointer' }} /> {COLOR_LABELS[name] || name} {set && ( )}
) })}
A color you have not set follows the preset. “Live” and “maintenance” status colors are never themed — green has to keep meaning live.
{/* ── Fonts ──────────────────────────────────────────────── */}
Fonts
{Object.keys(options.fonts).map((role) => ( ))}
{/* ── Structure ──────────────────────────────────────────── */}
Corners & depth
{options.radiusFields.map(({ name, token }) => ( ))}
{saved && Saved.} {error && {error}}

The accent reaches the mobile app and the Discord bot too — both theme themselves from this site’s public branding.

{/* ── Brand assets ───────────────────────────────────────── */}
) } const linkBtn = { border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: '0.72rem', cursor: 'pointer', padding: 0, }