Files
website/client/src/routes/admin/views/AppearanceAdmin.jsx
wtclaude 32a3ff104a feat(theming): wire the three navs and add the admin nav builder
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin
sidebar and the player portal now read their override row, and /admin/navigation
writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a
row into another existing section.

The merge always runs BEFORE the role and shard-feature filters in the layouts,
which are unchanged and remain the boundary. An override is presentation: it
cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored
`hidden: false` on a gated item shows nobody anything.

The design scoped these phases as client work, but the server had no way to
store a nav row: updateSettings validates and stringifies theme_visual and
brand_assets and lets everything else through, so a nav object would have been
written as "[object Object]" and read as absent for ever. utils/navOverrides.js
mirrors utils/brandAssets.js — strict on write with the offending key named,
forgiving on read. It validates shape only; whether a `to` exists is settled
client-side at merge time, because the base NAV arrays are client constants and
a server-side copy would be a second source of truth that drifts.

The nav editor cannot be hidden — its own toggle is disabled, the write path
drops `hidden` on that one `to`, and AdminLayout strips it again before merging,
which also covers a row edited straight in the database.

Orders are written only when the sequence actually differs from the code's, and
the comparison is restricted to the rows the editing admin can see, so renaming
one item does not pin the position of every other one and a role- or
feature-gated item missing from their palette is not mistaken for a reorder.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:02:33 -05:00

359 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <input type="color"> 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 <Loading />
if (error && !options) return <ErrorState message={error} />
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 (
<section style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 26 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
Colors, fonts and corner radius for the public site, this admin panel and the player portal.
{' '}
{stored ? (
<>This instance has a saved theme. <strong style={{ color: 'var(--muted)' }}>Reset to default</strong> deletes it and returns to the shipped look.</>
) : (
<>This instance has never been themed, so it uses the shipped look and its <code>BRAND_*</code> accent.</>
)}
</p>
{/* ── Preset ─────────────────────────────────────────────── */}
<div>
<span className="field-label">Preset</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
{options.presets.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setPreset(p.id)
setSaved(false)
}}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderRadius: 'var(--radius-input)',
border: `1px solid ${preset === p.id ? 'var(--accent)' : 'var(--line)'}`,
background: preset === p.id ? 'var(--blue)' : 'transparent',
color: preset === p.id ? 'var(--ink)' : 'var(--muted)',
cursor: 'pointer',
fontSize: '0.86rem',
}}
aria-pressed={preset === p.id}
>
{p.tokens ? (
<span style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--line)' }}>
{['--bg', '--panel-a', '--accent', '--ink'].map((t) => (
<span key={t} style={{ width: 11, height: 18, background: p.tokens[t] }} />
))}
</span>
) : (
<span style={{ width: 44, height: 18, borderRadius: 4, border: '1px dashed var(--line)' }} />
)}
{p.label}
</button>
))}
</div>
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
{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.'}
</span>
</div>
{/* ── Colors ─────────────────────────────────────────────── */}
<div>
<span className="field-label">Colors</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
{options.colorFields.map(({ name, token }) => {
const set = custom.colors[name] !== undefined
return (
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{/* <input type="color"> has no empty state, so an unset field
shows what it currently resolves to rather than black. */}
<input
type="color"
value={custom.colors[name] || baseTokens[token] || '#000000'}
onChange={(e) => 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' }}
/>
<span className="sans" style={{ flex: 1, fontSize: '0.82rem', color: set ? 'var(--ink)' : 'var(--dim)' }}>
{COLOR_LABELS[name] || name}
</span>
{set && (
<button type="button" onClick={clearField('colors', name)} className="sans" title="Follow the preset again" style={linkBtn}>
clear
</button>
)}
</div>
)
})}
</div>
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
A color you have not set follows the preset. Live and maintenance status colors are never themed green has to keep meaning live.
</span>
</div>
{/* ── Fonts ──────────────────────────────────────────────── */}
<div>
<span className="field-label">Fonts</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
{Object.keys(options.fonts).map((role) => (
<label key={role} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
{FONT_LABELS[role] || role}
</span>
<select
className="select"
value={custom.fonts[role] || ''}
onChange={(e) => (e.target.value ? setField('fonts', role)(e.target.value) : clearField('fonts', role)())}
>
<option value="">Follow the preset</option>
{options.fonts[role].map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
))}
</div>
</div>
{/* ── Structure ──────────────────────────────────────────── */}
<div>
<span className="field-label">Corners &amp; depth</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
{options.radiusFields.map(({ name, token }) => (
<label key={name} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
{RADIUS_LABELS[name] || name}
</span>
<input
className="input"
type="number"
min="0"
max={options.radiusMaxPx}
placeholder={(baseTokens[token] || '').replace('px', '')}
value={(custom.structure[name] || '').replace('px', '')}
onChange={(e) =>
e.target.value === ''
? clearField('structure', name)()
: setField('structure', name)(`${Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), options.radiusMaxPx)}px`)
}
/>
</label>
))}
</div>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
Card shadow
</span>
<select
className="select"
value={custom.structure.shadowDepth || ''}
onChange={(e) => (e.target.value ? setField('structure', 'shadowDepth')(e.target.value) : clearField('structure', 'shadowDepth')())}
>
<option value="">Follow the preset</option>
{options.shadows.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save theme'}
</button>
<button onClick={resetAll} disabled={busy || !stored} className="pill" title={stored ? 'Delete the saved theme' : 'Nothing to reset'}>
Reset to default
</button>
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
The accent reaches the mobile app and the Discord bot too both theme themselves from this
sites public branding.
</p>
{/* ── Brand assets ───────────────────────────────────────── */}
<BrandAssetsPanel initial={assets || {}} />
</section>
)
}
const linkBtn = {
border: 'none',
background: 'transparent',
color: 'var(--accent)',
fontSize: '0.72rem',
cursor: 'pointer',
padding: 0,
}