import { useEffect, useMemo, useState } from 'react' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { api } from '../../../api/client.js' import { useAuth } from '../../../contexts/AuthContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx' import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js' import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js' import PublicNavTree from './PublicNavTree.jsx' import { parseJsonSetting } from '../../../lib/settingsJson.js' import { refreshNavOverrides } from '../../../lib/useNavOverrides.js' import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx' import { NAV as ADMIN_NAV, navItemVisibleTo } from '../AdminLayout.jsx' import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx' // Admin · Navigation — phases 6-8 of docs/website/THEMING_AND_NAV.md. // // The three navs stay declared in code, each in the component that renders it; // this screen writes an override *layer* over them (§7). It can relabel, // reorder, hide and — on the admin sidebar — move a row into another existing // section, and nothing else. It cannot introduce a route and it cannot touch a // `roles` or `feature` gate, so the filters in the layouts still decide who sees // what, and they run after the merge. // // Three things shape the screen: // // • The palette is filtered to the editing admin's OWN visible rows (§8.1) — // the base array run through their role and this shard's feature gates. An // admin cannot drag in, and so can never accidentally advertise, something // they cannot see themselves. An override on a row they cannot see is // carried through their save untouched rather than quietly reset. // • The rows come from the same merge the site renders (buildNavRows), hidden // ones included, so the editor cannot show an order the nav does not use. // • Saving writes a settings row; "reset" DELETES it. Absence of the row is // what selects the coded default, so reset cannot store a copy of it — and a // save whose result is empty deletes the row for the same reason (§4.1). // The nav editor's own row. Hiding it would remove the only screen that can // un-hide it, so its eye toggle is disabled here and the server drops `hidden` // on it as well (server/src/utils/navOverrides.js) — a hand-written row cannot // do what the UI refuses. const SELF = '/admin/navigation' const TABS = [ { key: 'nav_public', label: 'Public site', hint: 'The header on every public page.' }, { key: 'nav_admin', label: 'Admin', hint: 'This sidebar. Rows can also move between sections.' }, { key: 'nav_player', label: 'Player portal', hint: 'The sidebar a signed-in player sees.' }, ] function DragHandle({ attributes, listeners, disabled }) { return ( ) } function EyeIcon({ off }) { return ( ) } /** * One editable nav row, shared by all three tabs. * * The destination control is generic because the two navs that have one mean * different things by it: the admin sidebar moves rows between the four coded * sections, the public header between admin-created dropdowns. Both are "pick a * container", so both get one ` onChange({ ...row, label: e.target.value })} aria-label={`Label for ${row.defaultLabel || row.to}`} style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem' }} /> {/* The route, for orientation — it is what the override is keyed by. Fixed and truncating rather than flexible: /admin/moderation/appeals would otherwise wrap and squeeze the label input it sits beside. */} {row.to} {renamed && ( )} {destinations && destinations.length > 0 && ( )} {onDelete && ( )} {/* A coded row is hidden, never removed — the route still exists. An admin-authored link is the opposite: there is nothing to fall back to, so it is deleted instead (the × above). */} {!onDelete && ( )} ) } export default function NavEditor() { const { user } = useAuth() const { refresh: refreshSite } = useSite() const shardFeatures = useShardFeatures() const [tab, setTab] = useState('nav_public') // Per nav: the editable groups, the overrides as loaded (so a row this admin // cannot see survives their save), and whether a settings row exists at all. const [state, setState] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [busy, setBusy] = useState(false) const [saved, setSaved] = useState('') const [dirty, setDirty] = useState({}) // The palette: each base nav, filtered to what THIS admin can see (§8.1). The // public nav's gates are the shard-feature ones; the admin nav's are roles. // The player portal has no gates at all. // The nav as coded, unfiltered. The palette below is what this admin may EDIT; // this is what still EXISTS, and the two are different questions. Saving needs // both: an entry for a row their palette filtered out must be carried through // rather than reset, and only an entry for a route the code no longer declares // at all should be dropped. const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV } const palettes = useMemo( () => ({ nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)), nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter( (g) => g.items.length > 0, ), nav_player: PLAYER_NAV, }), [shardFeatures, user?.role], ) useEffect(() => { let active = true api.admin .getSettings() .then((all) => { if (!active) return const next = {} for (const { key } of TABS) { const stored = parseJsonSetting(all[key]) // The public header is a tree (sections are entries in the top-level // order); the other two are the fixed-frame grouped/flat shape. next[key] = key === 'nav_public' ? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) } : { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) } } setState(next) }) .catch(() => active && setError('Could not load the navigation settings.')) .finally(() => active && setLoading(false)) return () => { active = false } // Loaded once; the palettes settle before the fetch resolves in practice, and // re-running on a feature flip would discard the admin's unsaved edits. // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) if (loading) return if (error && !state) return const current = state[tab] const isPublic = tab === 'nav_public' const groupTitles = isPublic ? [] : current.groups.map((g) => g.title).filter(Boolean) // Where each row is declared in code, so the section dropdown can offer only // the destinations an override is able to express. const baseGroups = new Map( (!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items ? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null])) : []), ) // The admin sidebar can only move a row between the four coded sections, and // "(no section)" only for a row coded into an untitled one — for anything else // it is a move an override cannot express (§6.4), so offering it would // silently do nothing. const groupDestinations = (baseGroup) => [ ...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []), ...groupTitles.map((t) => ({ value: t, label: t })), ] function mutate(updater) { setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } })) setDirty((d) => ({ ...d, [tab]: true })) setSaved('') } function setTree(tree) { setState((s) => ({ ...s, [tab]: { ...s[tab], tree } })) setDirty((d) => ({ ...d, [tab]: true })) setSaved('') } const onRowChange = (next) => mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) }))) // Sections change by dropdown, not by dragging: a drag that could land in // another list is a lot of interaction surface for something an admin does // once, and this keeps every drag a simple reorder. The row goes to the end of // its new section, where it is visible and can then be dragged into place. const onMoveGroup = (to, title) => mutate((groups) => { const moving = groups.flatMap((g) => g.items).find((i) => i.to === to) if (!moving) return groups return groups.map((g) => { if ((g.title ?? null) === title) return { ...g, items: [...g.items.filter((i) => i.to !== to), moving] } return { ...g, items: g.items.filter((i) => i.to !== to) } }) }) const onDragEnd = (groupIndex) => (event) => { const { active, over } = event if (!over || active.id === over.id) return mutate((groups) => groups.map((g, i) => { if (i !== groupIndex) return g const from = g.items.findIndex((it) => it.to === active.id) const to = g.items.findIndex((it) => it.to === over.id) if (from < 0 || to < 0) return g return { ...g, items: arrayMove(g.items, from, to) } }), ) } // Push a save into whatever is rendering that nav right now, so the admin sees // what they just did: the header re-reads the public settings, the two // authenticated sidebars re-read /settings/nav. async function propagate(key) { if (key === 'nav_public') await refreshSite() else await refreshNavOverrides() } async function save() { setBusy(true) setError('') try { const overrides = isPublic ? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored) : buildNavOverrides(current.groups, fullNavs[tab], current.stored) // A wrapper with an empty `items` and no sections/links says nothing // either, so "empty" is about the whole value, not just its key count. const empty = Object.keys(overrides).length === 0 || (overrides.items !== undefined && Object.keys(overrides.items).length === 0 && !overrides.sections?.length && !overrides.links?.length) // Nothing differs from the code default, so there is nothing to store — // and a row that says nothing would still read as "this nav was // customised". Delete it instead (§2, §4.1). if (empty) await api.admin.resetSetting(tab) else await api.admin.updateSettings({ [tab]: overrides }) setState((s) => ({ ...s, [tab]: { ...s[tab], stored: empty ? null : overrides, hasRow: !empty }, })) setDirty((d) => ({ ...d, [tab]: false })) setSaved(tab) await propagate(tab) } catch (err) { setError(err.message || 'Could not save this navigation.') } finally { setBusy(false) } } async function resetNav() { setBusy(true) setError('') try { await api.admin.resetSetting(tab) setState((s) => ({ ...s, [tab]: isPublic ? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) } : { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) }, })) setDirty((d) => ({ ...d, [tab]: false })) setSaved('') await propagate(tab) } catch (err) { setError(err.message || 'Could not reset this navigation.') } finally { setBusy(false) } } const activeTab = TABS.find((t) => t.key === tab) return (

Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this only decides what is advertised, and it can never show anyone a link their role or this shard’s visibility settings would hide.

{/* ── Tabs ───────────────────────────────────────────────── */}
{TABS.map((t) => ( ))}
{activeTab.hint}{' '} {current.hasRow ? 'This nav has saved overrides.' : 'This nav has never been customised, so it renders exactly as coded.'} {/* ── Rows ───────────────────────────────────────────────── */} {/* The public header gets its own editor: a section there is an entry in the top-level order that an admin created, not a fixed frame the code declares, so it is a tree rather than a list of groups. */} {isPublic ? ( ) : (
{current.groups.map((group, groupIndex) => (
{group.title && {group.title}} i.to)} strategy={verticalListSortingStrategy}>
    {group.items.map((row) => ( 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null} destination={group.title ?? ''} onDestination={(value) => onMoveGroup(row.to, value)} onChange={onRowChange} /> ))} {group.items.length === 0 && (
  • Empty — this section is not rendered until something is moved into it.
  • )}
))}
)}
{saved === tab && Saved.} {error && {error}}

Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard Visibility keeps whatever it was already set to.

) }