feat(theming): dropdown sections and added links in the public header

Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.

This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.

The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.

nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.

The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.

The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.

pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.

Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-08 00:42:19 -05:00
parent 32a3ff104a
commit b517d7b2df
10 changed files with 1335 additions and 51 deletions

View File

@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
// One dropdown section in the public header — a menu an admin created from
// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
//
// It **opens on click, never on hover**. Hover menus are unusable on touch, and
// the alternative (make the trigger a link too) means tapping to open navigates
// away instead. A section is a container, not a destination, so the trigger has
// no `to` at all.
//
// Everything else here is the keyboard and dismissal contract a menu needs:
// Escape closes and returns focus to the trigger, an outside press closes,
// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` +
// `aria-expanded` are what let a screen reader announce it as a menu rather than
// as a button that mysteriously changes the page.
export default function NavDropdown({ label, items, linkStyle }) {
const [open, setOpen] = useState(false)
const wrapRef = useRef(null)
const triggerRef = useRef(null)
const location = useLocation()
// The trigger shows the active treatment when the page you are on lives in
// this menu — otherwise entering a section makes the header look like nothing
// is selected.
const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
// Close on navigation. The menu is rendered inside a sticky header that
// survives route changes, so nothing else would dismiss it.
useEffect(() => setOpen(false), [location.pathname])
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
// `mousedown`, not `click`: closing on the press means a press that lands on
// another trigger opens that one in the same gesture.
const onOutside = (e) => {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onOutside)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onOutside)
}
}, [open])
// Roving focus with the arrow keys, wrapping at both ends.
const onMenuKeyDown = (e) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
e.preventDefault()
const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])]
if (links.length === 0) return
const at = links.indexOf(document.activeElement)
const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length
links[at === -1 ? 0 : next].focus()
}
return (
<div ref={wrapRef} style={{ position: 'relative' }} onKeyDown={onMenuKeyDown}>
<button
ref={triggerRef}
type="button"
className="pill"
aria-haspopup="true"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
...(holdsActive || open
? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' }
: {}),
}}
>
{label}
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{open && (
<div
role="menu"
aria-label={label}
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
minWidth: 190,
// The header wraps, so a menu near the right edge must not push the
// page sideways on a narrow screen.
maxWidth: 'calc(100vw - 24px)',
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: 6,
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line)',
background: 'var(--panel-flat)',
boxShadow: 'var(--shadow-card)',
zIndex: 40,
}}
>
{items.map((item) => (
<NavLink
key={item.kind === 'link' ? item.id : item.to}
to={item.to}
end={item.end}
role="menuitem"
data-menu-item=""
onClick={() => setOpen(false)}
className="sans"
style={({ isActive }) => ({
padding: '7px 10px',
borderRadius: 'var(--radius-input)',
fontSize: '0.85rem',
textDecoration: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
...linkStyle({ isActive }),
...(isActive ? {} : { color: 'var(--muted)' }),
})}
>
{item.label}
</NavLink>
))}
</div>
)}
</div>
)
}

View File

@@ -5,7 +5,8 @@ import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
import { applyNavOverrides } from '../lib/navOverrides.js'
import NavDropdown from './NavDropdown.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
// One consistent top nav for the whole public site. Every page gets the same
@@ -50,16 +51,19 @@ export default function SiteHeader() {
const shardFeatures = useShardFeatures()
// An admin may relabel, reorder and hide these entries from Admin →
// Navigation (THEMING_AND_NAV.md §7). Two things about the order here:
// Navigation, and may group them into dropdown sections alongside links of
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
//
// • the override merge runs FIRST and the feature filter after it, so the
// filter stays the boundary — an override cannot un-hide a shard surface
// this viewer may not see, whatever it says;
// • with no stored row, applyNavOverrides returns NAV itself, so an
// this viewer may not see, whatever it says. `pruneNav` applies the same
// check inside a section and drops one it leaves empty, so a dropdown
// never opens onto nothing;
// • with no stored row this is the coded NAV, in code order, so an
// untouched instance renders exactly what it renders today.
const nav = useMemo(() => {
const merged = applyNavOverrides(NAV, parseJsonSetting(settings.nav_public))
return merged.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
}, [settings.nav_public, shardFeatures])
// Where the auth entry points: staff → admin, player → portal, else sign in.
@@ -93,11 +97,15 @@ export default function SiteHeader() {
{siteTitle}
</Link>
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{nav.map((l) => (
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
{l.label}
</NavLink>
))}
{nav.map((l) =>
l.kind === 'section' ? (
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
) : (
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
{l.label}
</NavLink>
),
)}
{!loading && (
<NavLink
to={account.to}

View File

@@ -263,4 +263,240 @@ export function buildNavOverrides(groups, baseNav, stored = null) {
return out
}
// ── The public header: dropdown sections and added links ────────────────
//
// Phase 10. The public nav is the one nav an admin can restructure rather than
// only reorder: they may create dropdown **sections**, drop coded entries into
// them, and add **links** of their own to pages on this site.
//
// The invariant §7 rests on survives, and it survives structurally rather than
// by vigilance: coded entries stay keyed by a `to` the base array must declare,
// so an override still cannot invent a route or touch a `roles`/`feature` gate,
// while everything that CAN name an arbitrary path lives in `links` where the
// path rule is applied. An added link carries no gate of its own and needs none
// — the page behind it enforces its own access, so a link to somewhere the
// viewer cannot reach 403s exactly as typing the URL would.
//
// Stored shape (server/src/utils/navOverrides.js is the writer):
// { items: {"<to>": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] }
// A bare map is still read as the items map — unambiguous, because every item
// key is a path and so can never be the string `items`.
function unwrapPublic(overrides) {
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
return { items: {}, sections: [], links: [] }
}
const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items)
const items = wrapped ? overrides.items : overrides
const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : []
const links = wrapped && Array.isArray(overrides.links) ? overrides.links : []
return { items, sections, links }
}
// Forgiving, like every other read here: an entry that is not usable is dropped
// and its neighbours kept.
function readSections(sections) {
const out = []
const seen = new Set()
for (const s of sections) {
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue
if (typeof s.label !== 'string' || !s.label.trim()) continue
seen.add(s.id)
out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined })
}
return out
}
function readLinks(links, knownSections) {
const out = []
const seen = new Set()
for (const l of links) {
if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue
if (typeof l.label !== 'string' || !l.label.trim()) continue
// Same rule the server writes by. A stored value that would leave the origin
// is dropped rather than rendered, so a hand-edited row cannot put an
// off-site link in the header.
if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue
seen.add(l.id)
out.push({
id: l.id,
label: l.label.trim(),
to: l.to,
order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined,
section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null,
})
}
return out
}
/**
* The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`.
*
* @param {Array} baseNav the hardcoded public NAV — still the only source of
* `to`, `feature` and `end` for a coded entry
* @param {object|null} overrides the parsed nav_public row
* @param {{keepHidden?: boolean}} [opts] the editor keeps hidden entries so
* they can be un-hidden, and gets `defaultLabel` for the reset affordance;
* the header must not render them at all
* @returns {Array}
*/
export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) {
if (!Array.isArray(baseNav)) return []
const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides)
const sections = readSections(rawSections)
const knownSections = new Set(sections.map((s) => s.id))
const links = readLinks(rawLinks, knownSections)
// Coded entries, keyed by a `to` the base array declares. Anything else in the
// map is dropped here, exactly as in applyNavOverrides.
const known = new Set(baseNav.map((i) => i.to))
const entries = new Map()
for (const [to, raw] of Object.entries(items)) {
if (!known.has(to)) continue
const entry = cleanEntry(raw, new Set())
if (!entry) continue
if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section
entries.set(to, entry)
}
const nodes = []
baseNav.forEach((item, index) => {
const o = entries.get(item.to)
if (o?.hidden && !keepHidden) return
nodes.push({
kind: 'item',
...item,
...(o?.label ? { label: o.label } : {}),
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
section: o?.section ?? null,
__order: o?.order,
__index: index,
})
})
// An admin-created entity with no stored order appends after the coded ones,
// in creation order, rather than jumping to the front on a 0 default.
let next = baseNav.length
for (const section of sections) {
nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ })
}
for (const link of links) {
nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ })
}
const place = (list) =>
list
.map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
.map(({ n }) => {
const { __order, __index, section, ...rest } = n
return rest
})
const top = place(nodes.filter((n) => n.kind === 'section' || !n.section))
return top.map((node) =>
node.kind === 'section'
? { ...node, items: place(nodes.filter((n) => n.section === node.id)) }
: node,
)
}
/**
* Apply the caller's visibility gate — and drop a section it leaves empty.
*
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
* with real correctness risk: a section whose every entry is hidden by shard
* visibility must not render as a menu that opens onto nothing. The predicate
* stays the caller's, so this module still knows nothing about shard features.
*
* Added links carry no gate, so they are always visible — see the note above.
*
* @param {Array} tree from buildPublicNav
* @param {(item: object) => boolean} isVisible applied to coded items only
* @returns {Array}
*/
export function pruneNav(tree, isVisible) {
if (!Array.isArray(tree)) return []
const keep = (node) => node.kind !== 'item' || isVisible(node)
return tree
.map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node))
.filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node)))
}
/**
* The editor's tree back as a nav_public value to store.
*
* Returns the **bare items map** when there are no sections and no added links,
* so a nav that does not use this feature stores exactly what phases 6-8 stored.
*
* @param {Array} tree the editor's current tree
* @param {Array} baseNav the hardcoded public NAV
* @param {object|null} stored as loaded, so an entry for a feature-gated item
* this admin could not see survives their save
* @returns {object} `{}` when nothing differs from the code default
*/
export function buildPublicNavOverrides(tree, baseNav, stored = null) {
if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {}
const baseLabels = new Map(baseNav.map((i) => [i.to, i.label]))
const sections = []
const links = []
const items = {}
// Flatten to (node, containerId, indexInContainer), which is all the writer
// needs: a section's own position is its index in the top-level list.
const placed = []
tree.forEach((node, index) => {
placed.push({ node, section: null, index })
if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i }))
})
// Orders are written whenever this nav has any structure of its own: a section
// exists only because the admin put it somewhere, so its position is never
// "whatever the code says". Without sections the rule is phase 6-8's — write
// orders only if the sequence actually moved.
const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link')
const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to))
const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to)
const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to)
const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i])
const writeOrder = hasStructure || moved
for (const { node, section, index } of placed) {
if (node.kind === 'section') {
sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) })
continue
}
if (node.kind === 'link') {
links.push({
id: node.id,
label: (node.label || '').trim() || node.to,
to: node.to,
...(section ? { section } : {}),
...(writeOrder ? { order: index } : {}),
})
continue
}
const entry = {}
const label = typeof node.label === 'string' ? node.label.trim() : ''
if (label && label !== baseLabels.get(node.to)) entry.label = label
if (node.hidden === true) entry.hidden = true
if (section) entry.section = section
if (writeOrder) entry.order = index
if (Object.keys(entry).length > 0) items[node.to] = entry
}
// Carry through an entry for a coded item this admin's palette never showed
// them (shard-feature gated), so their save does not silently reset it.
const { items: storedItems } = unwrapPublic(stored)
for (const [to, entry] of Object.entries(storedItems)) {
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
}
if (sections.length === 0 && links.length === 0) return items
const out = { items }
if (sections.length) out.sections = sections
if (links.length) out.links = links
return out
}
export default applyNavOverrides

View File

@@ -14,7 +14,8 @@ 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 } from '../../../lib/navOverrides.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'
@@ -95,9 +96,23 @@ function EyeIcon({ off }) {
)
}
function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.to })
const renamed = row.label !== row.defaultLabel
/**
* 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 `<select>` rather than cross-container dragging —
* which is a lot of interaction surface for something an admin does once.
*
* @param {Array<{value: string, label: string}>} [destinations] omit for a nav
* with no containers (the player portal)
* @param {() => void} [onDelete] only an admin-authored link can be deleted;
* a coded row is hidden, never removed
*/
export function Row({ row, id, destinations, destination, onDestination, onChange, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
const renamed = row.defaultLabel !== undefined && row.label !== row.defaultLabel
const locked = row.to === SELF
return (
@@ -121,10 +136,10 @@ function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup
<input
className="input"
value={row.label}
placeholder={row.defaultLabel}
placeholder={row.defaultLabel || row.to}
maxLength={64}
onChange={(e) => onChange({ ...row, label: e.target.value })}
aria-label={`Label for ${row.defaultLabel}`}
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
@@ -157,28 +172,44 @@ function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup
reset
</button>
)}
{groupTitles.length > 0 && (
{destinations && destinations.length > 0 && (
<select
className="select"
value={currentGroup ?? ''}
onChange={(e) => onMoveGroup(row.to, e.target.value || null)}
aria-label={`Section for ${row.defaultLabel}`}
value={destination ?? ''}
onChange={(e) => onDestination(e.target.value || null)}
aria-label={`Section for ${row.defaultLabel || row.to}`}
style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
>
{/* "(no section)" is offered only to a row that is coded into one of
the untitled groups (Dashboard, Account) — for anything else it is
a move that cannot be stored: an override names an existing titled
section or nothing at all (§6.4), so picking it would silently do
nothing. Such a row can still move OUT and back, which clears the
override rather than storing a null group. */}
{baseGroup === null && <option value="">(no section)</option>}
{groupTitles.map((t) => (
<option key={t} value={t}>
{t}
{destinations.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
)}
{onDelete && (
<button
type="button"
className="sans"
title="Remove this link"
onClick={onDelete}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: 'var(--muted)',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '0.76rem',
}}
>
×
</button>
)}
{/* 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 && (
<button
type="button"
className="sans"
@@ -205,6 +236,7 @@ function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup
>
<EyeIcon off={row.hidden} />
</button>
)}
</li>
)
}
@@ -226,6 +258,13 @@ export default function NavEditor() {
// 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)),
@@ -246,7 +285,12 @@ export default function NavEditor() {
const next = {}
for (const { key } of TABS) {
const stored = parseJsonSetting(all[key])
next[key] = { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
// 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)
})
@@ -269,14 +313,23 @@ export default function NavEditor() {
if (error && !state) return <ErrorState message={error} />
const current = state[tab]
const groupTitles = current.groups.map((g) => g.title).filter(Boolean)
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(
(Array.isArray(palettes[tab]) && palettes[tab][0]?.items
(!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) } }))
@@ -284,6 +337,12 @@ export default function NavEditor() {
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)) })))
@@ -327,8 +386,17 @@ export default function NavEditor() {
setBusy(true)
setError('')
try {
const overrides = buildNavOverrides(current.groups, palettes[tab], current.stored)
const empty = Object.keys(overrides).length === 0
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).
@@ -353,7 +421,12 @@ export default function NavEditor() {
setError('')
try {
await api.admin.resetSetting(tab)
setState((s) => ({ ...s, [tab]: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) } }))
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)
@@ -410,6 +483,12 @@ export default function NavEditor() {
</span>
{/* ── 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 ? (
<PublicNavTree tree={current.tree} onChange={setTree} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{current.groups.map((group, groupIndex) => (
<div key={group.title ?? `group-${groupIndex}`}>
@@ -420,12 +499,12 @@ export default function NavEditor() {
{group.items.map((row) => (
<Row
key={row.to}
id={row.to}
row={row}
groupTitles={groupTitles}
currentGroup={group.title ?? null}
baseGroup={baseGroups.get(row.to) ?? null}
destinations={groupTitles.length > 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null}
destination={group.title ?? ''}
onDestination={(value) => onMoveGroup(row.to, value)}
onChange={onRowChange}
onMoveGroup={onMoveGroup}
/>
))}
{group.items.length === 0 && (
@@ -439,6 +518,7 @@ export default function NavEditor() {
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">

View File

@@ -0,0 +1,310 @@
import { 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 Modal from '../../../components/Modal.jsx'
import { Row } from './NavEditor.jsx'
// The Public tab of Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
//
// The public header is the one nav an admin can restructure rather than only
// reorder, so it needs its own editor: a **section is itself an entry in the
// top-level order**, which the fixed coded sections of the admin sidebar never
// are. That is the whole reason this is not the grouped editor with a different
// label — there, groups are a fixed frame and only membership moves.
//
// The tree is `[{kind: 'item' | 'link' | 'section', ...}]`, one level deep, and
// comes from the same `buildPublicNav` the header renders, so what an admin
// drags is what visitors get.
const uid = (prefix) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`
// A path on this site, matching what the server will accept. Checked here so the
// admin gets the message while the field is in front of them; the server's 400
// stays the backstop, not the first feedback.
export function badLinkPath(value) {
const v = (value || '').trim()
if (!v) return 'Enter a path.'
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('//')) {
return 'Links must point somewhere on this site — start with “/”.'
}
if (!v.startsWith('/')) return 'Start the path with “/”, for example /wiki/new-player-guide.'
if (/[\s<>"'\\]/.test(v)) return 'A path cannot contain spaces or quotes.'
if (v.length > 128) return 'That path is too long.'
return null
}
function SectionCard({ section, index, children, onChange, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
return (
<li
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
listStyle: 'none',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-card)',
background: isDragging ? 'var(--blue)' : 'transparent',
padding: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="sans"
aria-label={`Reorder ${section.label}`}
{...attributes}
{...listeners}
style={{ border: 'none', background: 'transparent', color: 'var(--dim)', cursor: 'grab', padding: '2px 4px', touchAction: 'none' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<circle cx="9" cy="6" r="1.6" /><circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" /><circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" /><circle cx="15" cy="18" r="1.6" />
</svg>
</button>
<input
className="input"
value={section.label}
maxLength={64}
placeholder="Section name"
onChange={(e) => onChange({ ...section, label: e.target.value })}
aria-label={`Name for section ${index + 1}`}
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem', fontWeight: 600 }}
/>
<span className="sans dim" style={{ fontSize: '0.7rem' }}>dropdown</span>
<button
type="button"
className="sans"
title="Delete this section — the entries inside move back out, they are not removed"
onClick={onDelete}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: 'var(--muted)',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '0.76rem',
}}
>
×
</button>
</div>
{children}
</li>
)
}
export default function PublicNavTree({ tree, onChange }) {
const [adding, setAdding] = useState(null) // {label, to, error} while the modal is open
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const sections = tree.filter((n) => n.kind === 'section')
const destinations = [{ value: '', label: 'Top level' }, ...sections.map((s) => ({ value: s.id, label: s.label || 'Section' }))]
const keyOf = (node) => (node.kind === 'item' ? node.to : node.id)
// Every mutation rebuilds the tree; there is no partial in-place editing, which
// keeps "what will be saved" exactly "what is on screen".
const replace = (nextTree) => onChange(nextTree)
const updateNode = (key, next) =>
replace(
tree.map((node) => {
if (keyOf(node) === key) return next
if (node.kind !== 'section') return node
return { ...node, items: node.items.map((child) => (keyOf(child) === key ? next : child)) }
}),
)
// Moving between containers is the dropdown, not a drag. The entry lands at the
// end of its destination, where it is visible and can then be dragged home.
const moveTo = (key, sectionId) => {
let moving = null
const stripped = tree
.map((node) => {
if (node.kind === 'section') {
const items = node.items.filter((child) => {
if (keyOf(child) !== key) return true
moving = child
return false
})
return { ...node, items }
}
if (keyOf(node) === key) {
moving = node
return null
}
return node
})
.filter(Boolean)
if (!moving) return
if (!sectionId) return replace([...stripped, moving])
return replace(
stripped.map((node) => (node.kind === 'section' && node.id === sectionId ? { ...node, items: [...node.items, moving] } : node)),
)
}
const addSection = () => replace([...tree, { kind: 'section', id: uid('sec'), label: 'New section', items: [] }])
// Deleting a section must NOT delete what is inside it: those are coded pages
// and the admin's own links, and losing them to a mis-click would be the one
// destructive act this screen could commit. They move back to the top level.
const deleteSection = (id) => {
const section = tree.find((n) => n.kind === 'section' && n.id === id)
if (!section) return
replace([...tree.filter((n) => keyOf(n) !== id), ...(section.items || [])])
}
const deleteLink = (id) =>
replace(
tree
.filter((n) => keyOf(n) !== id)
.map((n) => (n.kind === 'section' ? { ...n, items: n.items.filter((c) => keyOf(c) !== id) } : n)),
)
const submitLink = () => {
const error = badLinkPath(adding.to)
if (error) return setAdding({ ...adding, error })
const label = adding.label.trim()
if (!label) return setAdding({ ...adding, error: 'Give the link a name.' })
replace([...tree, { kind: 'link', id: uid('lnk'), label, to: adding.to.trim() }])
return setAdding(null)
}
const onDragEnd = (containerId) => (event) => {
const { active, over } = event
if (!over || active.id === over.id) return
if (containerId === null) {
const from = tree.findIndex((n) => keyOf(n) === active.id)
const to = tree.findIndex((n) => keyOf(n) === over.id)
if (from < 0 || to < 0) return
return replace(arrayMove(tree, from, to))
}
return replace(
tree.map((node) => {
if (node.kind !== 'section' || node.id !== containerId) return node
const from = node.items.findIndex((c) => keyOf(c) === active.id)
const to = node.items.findIndex((c) => keyOf(c) === over.id)
if (from < 0 || to < 0) return node
return { ...node, items: arrayMove(node.items, from, to) }
}),
)
}
const renderRow = (node, sectionId) => (
<Row
key={keyOf(node)}
id={keyOf(node)}
row={node}
destinations={destinations}
destination={sectionId ?? ''}
onDestination={(value) => moveTo(keyOf(node), value)}
onChange={(next) => updateNode(keyOf(node), next)}
onDelete={node.kind === 'link' ? () => deleteLink(node.id) : undefined}
/>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(null)}>
<SortableContext items={tree.map(keyOf)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: 0, padding: 0 }}>
{tree.map((node, index) =>
node.kind === 'section' ? (
<SectionCard
key={node.id}
section={node}
index={index}
onChange={(next) => updateNode(node.id, next)}
onDelete={() => deleteSection(node.id)}
>
{/* A nested context, so a drag inside a dropdown reorders that
dropdown rather than escaping into the header. */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(node.id)}>
<SortableContext items={(node.items || []).map(keyOf)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '10px 0 0', padding: '0 0 0 22px' }}>
{(node.items || []).map((child) => renderRow(child, node.id))}
{(node.items || []).length === 0 && (
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '4px 2px' }}>
Empty an empty dropdown is not shown on the site.
</li>
)}
</ul>
</SortableContext>
</DndContext>
</SectionCard>
) : (
renderRow(node, null)
),
)}
</ul>
</SortableContext>
</DndContext>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button type="button" className="pill" onClick={addSection}>
+ Add dropdown section
</button>
<button type="button" className="pill" onClick={() => setAdding({ label: '', to: '', error: null })}>
+ Add link
</button>
</div>
{adding && (
<Modal
title="Add a link"
onClose={() => setAdding(null)}
width={480}
footer={
<>
<button className="pill" onClick={() => setAdding(null)}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submitLink}>Add link</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label style={{ display: 'block' }}>
<span className="field-label">Name</span>
<input
className="input"
value={adding.label}
maxLength={64}
placeholder="Player Guide"
onChange={(e) => setAdding({ ...adding, label: e.target.value, error: null })}
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Path on this site</span>
<input
className="input"
value={adding.to}
maxLength={128}
placeholder="/wiki/new-player-guide"
onChange={(e) => setAdding({ ...adding, to: e.target.value, error: null })}
/>
</label>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
Links point somewhere on this site a wiki page, a custom page, any section of the site.
They are not gated: the page itself still decides who may open it, so a link to something
restricted behaves exactly as typing its address would.
</p>
{adding.error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{adding.error}</span>}
</div>
</Modal>
)}
</div>
)
}

View File

@@ -1,7 +1,14 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyNavOverrides, buildNavRows, buildNavOverrides } from '../src/lib/navOverrides.js'
import {
applyNavOverrides,
buildNavRows,
buildNavOverrides,
buildPublicNav,
pruneNav,
buildPublicNavOverrides,
} from '../src/lib/navOverrides.js'
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
// of this feature with real correctness risk, so it is tested in isolation from
@@ -348,3 +355,190 @@ test('degenerate input yields an empty result rather than throwing', () => {
assert.deepEqual(buildNavOverrides(null, FLAT), {})
assert.deepEqual(buildNavOverrides([], null), {})
})
// ── The public header: sections and added links (phase 10) ────────────────
//
// The one nav an admin can restructure rather than only reorder. The invariant
// that has to survive is §7's, in its narrower form: a CODED entry still cannot
// have its `to` or `feature` touched, and everything that can name an arbitrary
// path lives in `links`, where the path rule applies.
const PUB = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'About', to: '/site/about' },
]
const shape = (tree) =>
tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))
test('no override yields the coded header, in code order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('a phase 6-8 bare map still reads as the items map', () => {
// Nothing has shipped, but a row written during review must not become
// unreadable just because the wrapper arrived.
assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
'Home',
'Announcements',
'Champions',
'Guilds',
'About',
])
})
const SECTIONED = {
items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
}
test('a section collects its members and sits in the top-level order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('an added link is kept apart from the coded items', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
assert.equal(link.to, '/wiki/new-player-guide')
assert.equal(link.id, 'lnk_bbbb')
// It carries no gate of its own — that is the documented contract, and the
// page behind it is what actually enforces access.
assert.equal(link.feature, undefined)
assert.equal(link.roles, undefined)
})
test('an off-origin link is dropped rather than rendered', () => {
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
assert.equal(
tree.some((n) => n.kind === 'link'),
false,
`${to} should be dropped`,
)
}
})
test('an item naming a section that does not exist stays at the top level', () => {
const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('an override still cannot introduce a coded route', () => {
const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
assert.equal(
tree.some((n) => n.to === '/site/secret'),
false,
)
})
test('hidden entries are dropped for the site and kept for the editor', () => {
const overrides = { items: { '/site/news': { hidden: true } } }
assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
})
// ── pruneNav: the empty dropdown ──────────────────────────────────────────
test('a section keeps the entries the viewer may see', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const out = pruneNav(tree, (i) => i.feature !== 'guilds')
assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
})
test('a section whose every entry is gated out does not render at all', () => {
// The case that matters: a dropdown that opens onto nothing is worse than no
// dropdown, and shard visibility can empty one at any time.
const overrides = {
items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
sections: [{ id: 'sec_aaaa', label: 'The World' }],
}
const tree = buildPublicNav(PUB, overrides)
assert.deepEqual(shape(pruneNav(tree, () => true)), [
'Home',
'News',
'About',
{ 'The World': ['Champions', 'Guilds'] },
])
assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
})
test('an added link is never pruned — it carries no gate', () => {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
assert.equal(
pruneNav(tree, () => false).some((n) => n.kind === 'link'),
true,
)
})
// ── The editor round trip ─────────────────────────────────────────────────
test('an untouched public editor saves nothing', () => {
assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
})
test('a nav with no sections still stores the plain items map', () => {
// Adding this feature changed nothing for a nav that does not use it.
const tree = buildPublicNav(PUB, null, { keepHidden: true })
tree[1].label = 'Announcements'
const out = buildPublicNavOverrides(tree, PUB)
assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
assert.equal(out.items, undefined)
})
test('the sectioned round trip is stable and renders what the editor showed', () => {
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const first = buildPublicNavOverrides(tree, PUB)
const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
assert.deepEqual(second, first)
assert.deepEqual(shape(buildPublicNav(PUB, first)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('deleting a section returns its entries to the top level, never deletes them', () => {
// The one destructive act this screen could commit, so it is locked here.
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const section = tree.find((n) => n.kind === 'section')
const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
const out = buildPublicNavOverrides(flattened, PUB)
const rendered = buildPublicNav(PUB, out)
assert.equal(
rendered.some((n) => n.kind === 'section'),
false,
)
assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
})
test('an override for a feature-gated item outside the palette survives a save', () => {
// §8.1 filters the editor to what this admin can see. The rows come from their
// palette, but membership is judged against the FULL coded nav — otherwise a
// row a shard feature hid from them is indistinguishable from a deleted route,
// and their save would silently reset it.
const palette = PUB.filter((i) => i.feature !== 'champs')
// The editor was opened on a nav that only hides champs — which their palette
// does not show them. `stored` additionally carries a label for a row they CAN
// see, and which they have since reset.
const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
const out = buildPublicNavOverrides(tree, PUB, stored)
assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
})
test('a stored entry for a route the code no longer declares is dropped on save', () => {
const tree = buildPublicNav(PUB, null, { keepHidden: true })
assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
})

View File

@@ -34,7 +34,7 @@ settingsRouter.put(
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Update site settings (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides carry only label/order/hidden/group; whether a key names a route the nav declares is settled client-side at merge time.'
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -25,10 +25,11 @@
// with the offending key named, while a bad stored value is dropped entry by
// entry so one hand-edited row does not cost the admin the rest of their nav.
// The four overridable fields. `group` is only meaningful on the grouped admin
// nav, but accepting it everywhere costs nothing — the merge util drops a group
// the base nav does not declare, and the flat navs declare none at all.
const FIELDS = ['label', 'order', 'hidden', 'group']
// The overridable fields on a CODED item. `group` is only meaningful on the
// grouped admin nav and `section` only on the public header, but accepting both
// everywhere costs nothing — the merge util drops a group the base nav does not
// declare, and a section id no `sections` entry declares.
const FIELDS = ['label', 'order', 'hidden', 'group', 'section']
// Bounds. None of these is a security control on its own — the row is written by
// an admin and rendered as text by React — they keep a single settings row from
@@ -38,6 +39,20 @@ const MAX_ENTRIES = 200
const MAX_PATH = 128
const MAX_LABEL = 64
const MAX_GROUP = 64
const MAX_SECTIONS = 12
const MAX_LINKS = 40
// Only the public header supports admin-created dropdown sections and
// admin-authored links (THEMING_AND_NAV.md §7, Phase 10). The admin sidebar has
// its own coded sections and the player portal is three flat rows, so both keep
// the bare items map; `sections`/`links` are dropped for them rather than
// rejected, the same posture as every other unusable field here.
const SECTIONED_KEYS = ['nav_public']
// Generated by the editor, never typed. Constrained so a stored id is safe to
// use as a React key and as a DOM id fragment without further escaping.
const SECTION_ID = /^sec_[a-z0-9]{4,16}$/
const LINK_ID = /^lnk_[a-z0-9]{4,16}$/
// The one item an override may never hide: the nav editor itself. An admin who
// hid it would lose the only screen that can un-hide it, and "type the URL from
@@ -61,6 +76,100 @@ function isNavPath(value) {
return true
}
/**
* Split a stored value into its three parts.
*
* The public header grew dropdown sections in Phase 10, so `nav_public` may be
* a wrapper — `{ items, sections, links }` — while the other two navs stay the
* bare items map phases 6-8 wrote. **A bare map is still read as the items
* map**, which is unambiguous because every item key is a path beginning with
* `/` and so can never be the string `items`.
*
* @param {object} value a parsed, non-array object
* @returns {{items: object, sections: unknown, links: unknown, wrapped: boolean}}
*/
function unwrap(value) {
const wrapped = value.items && typeof value.items === 'object' && !Array.isArray(value.items)
if (!wrapped) return { items: value, sections: undefined, links: undefined, wrapped: false }
return { items: value.items, sections: value.sections, links: value.links, wrapped: true }
}
// A section is a dropdown an admin created: a label and a position, no route.
// It is never itself a link — it only opens — so there is no `to` to validate.
function validateSections(sections, key) {
if (sections === undefined || sections === null) return { ok: true }
if (!Array.isArray(sections)) return { ok: false, message: `${key}.sections must be an array` }
if (sections.length > MAX_SECTIONS) {
return { ok: false, message: `${key} may hold at most ${MAX_SECTIONS} sections` }
}
const seen = new Set()
for (const section of sections) {
if (!section || typeof section !== 'object' || Array.isArray(section)) {
return { ok: false, message: `${key}.sections entries must be objects` }
}
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id)) {
return { ok: false, message: `${key}.sections has an entry with an invalid id` }
}
if (seen.has(section.id)) {
return { ok: false, message: `${key}.sections has a duplicate id '${section.id}'` }
}
seen.add(section.id)
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) {
return { ok: false, message: `${key}.sections['${section.id}'].label must be text of at most ${MAX_LABEL} characters` }
}
if (section.order !== undefined && (typeof section.order !== 'number' || !Number.isFinite(section.order))) {
return { ok: false, message: `${key}.sections['${section.id}'].order must be a number` }
}
}
return { ok: true }
}
// A link is the one thing an admin may ADD to a nav, and the only place a `to`
// is not required to already exist in code. It is kept in its own array rather
// than in `items` on purpose: `items` may only key routes the base array
// declares, so an override structurally cannot invent a route, and everything
// that CAN name an arbitrary path is here where the path rule is applied.
//
// A link carries no `roles` or `feature` of its own. It does not need one: the
// page behind it enforces its own access, so a link to somewhere the viewer
// cannot reach 403s exactly as typing the URL would (§7).
function validateLinks(links, key) {
if (links === undefined || links === null) return { ok: true }
if (!Array.isArray(links)) return { ok: false, message: `${key}.links must be an array` }
if (links.length > MAX_LINKS) {
return { ok: false, message: `${key} may hold at most ${MAX_LINKS} added links` }
}
const seen = new Set()
for (const link of links) {
if (!link || typeof link !== 'object' || Array.isArray(link)) {
return { ok: false, message: `${key}.links entries must be objects` }
}
if (typeof link.id !== 'string' || !LINK_ID.test(link.id)) {
return { ok: false, message: `${key}.links has an entry with an invalid id` }
}
if (seen.has(link.id)) {
return { ok: false, message: `${key}.links has a duplicate id '${link.id}'` }
}
seen.add(link.id)
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) {
return { ok: false, message: `${key}.links['${link.id}'].label must be text of at most ${MAX_LABEL} characters` }
}
// The whole point of the restriction: an added link points somewhere on this
// site. No scheme, no `//host` — the nav is not a place to send visitors off
// to an origin the operator does not control.
if (!isNavPath(link.to)) {
return { ok: false, message: `${key}.links['${link.id}'].to must be a path on this site, such as /wiki/new-player-guide` }
}
if (link.order !== undefined && (typeof link.order !== 'number' || !Number.isFinite(link.order))) {
return { ok: false, message: `${key}.links['${link.id}'].order must be a number` }
}
if (link.section !== undefined && link.section !== null && typeof link.section !== 'string') {
return { ok: false, message: `${key}.links['${link.id}'].section must be a section id` }
}
}
return { ok: true }
}
/**
* Validate a nav-override object for WRITING. Strict: names the offending key.
* @param {unknown} value the parsed object, or null to clear every override
@@ -72,7 +181,16 @@ function validateNavOverrides(value, key = 'nav') {
if (typeof value !== 'object' || Array.isArray(value)) {
return { ok: false, message: `${key} must be a JSON object` }
}
const entries = Object.entries(value)
const { items, sections, links } = unwrap(value)
if (!items || typeof items !== 'object' || Array.isArray(items)) {
return { ok: false, message: `${key}.items must be a JSON object` }
}
const sectionCheck = validateSections(sections, key)
if (!sectionCheck.ok) return sectionCheck
const linkCheck = validateLinks(links, key)
if (!linkCheck.ok) return linkCheck
const entries = Object.entries(items)
if (entries.length > MAX_ENTRIES) {
return { ok: false, message: `${key} may hold at most ${MAX_ENTRIES} entries` }
}
@@ -96,6 +214,9 @@ function validateNavOverrides(value, key = 'nav') {
if (field === 'order' && (typeof fieldValue !== 'number' || !Number.isFinite(fieldValue))) {
return { ok: false, message: `${key}['${to}'].order must be a number` }
}
if (field === 'section' && fieldValue !== null && typeof fieldValue !== 'string') {
return { ok: false, message: `${key}['${to}'].section must be a section id` }
}
// `hidden: false` is not an error — it is simply the default, and the
// editor sends it while a row is being edited. It is dropped below, never
// stored, because hiding is subtractive only (§7): a stored `false` could
@@ -117,15 +238,78 @@ function validateNavOverrides(value, key = 'nav') {
* was customised" (§4.1);
* • reading — a hand-edited entry is dropped and its neighbours kept.
*
* Sections and added links are honored only for the navs that can render them
* (`nav_public`), and a `section` naming no surviving section falls back to the
* top level rather than stranding the item in a dropdown that is not there.
*
* The return shape mirrors the input: a nav with no sections and no added links
* resolves to the bare items map phases 6-8 wrote, so adding this feature
* changed nothing at all for a nav that does not use it.
*
* @param {object|null} value an object, or a parseJsonSetting result
* @param {string} [key] the settings key, so the un-hideable rule can apply
* @returns {object} a new object, `{}` when nothing survives
*/
function resolveNavOverrides(value, key = 'nav') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const { items, sections, links } = unwrap(value)
if (!items || typeof items !== 'object' || Array.isArray(items)) return {}
const sectioned = SECTIONED_KEYS.includes(key)
const cleanSections = sectioned ? resolveSections(sections) : []
const known = new Set(cleanSections.map((s) => s.id))
const cleanLinks = sectioned ? resolveLinks(links, known) : []
const out = resolveItems(items, key, known)
if (cleanSections.length === 0 && cleanLinks.length === 0) return out
// A section with nothing in it renders as an empty dropdown, so an admin who
// emptied one has simply stopped using it — but it is theirs to keep until
// they delete it, and the editor is where that happens. Kept here; the
// renderer drops it (client/src/lib/navOverrides.js pruneNav).
const wrapper = { items: out }
if (cleanSections.length) wrapper.sections = cleanSections
if (cleanLinks.length) wrapper.links = cleanLinks
return wrapper
}
function resolveSections(sections) {
const out = []
const seen = new Set()
if (!Array.isArray(sections)) return out
for (const section of sections.slice(0, MAX_SECTIONS)) {
if (!section || typeof section !== 'object' || Array.isArray(section)) continue
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id) || seen.has(section.id)) continue
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) continue
seen.add(section.id)
const clean = { id: section.id, label: section.label.trim() }
if (typeof section.order === 'number' && Number.isFinite(section.order)) clean.order = section.order
out.push(clean)
}
return out
}
function resolveLinks(links, knownSections) {
const out = []
const seen = new Set()
if (!Array.isArray(links)) return out
for (const link of links.slice(0, MAX_LINKS)) {
if (!link || typeof link !== 'object' || Array.isArray(link)) continue
if (typeof link.id !== 'string' || !LINK_ID.test(link.id) || seen.has(link.id)) continue
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) continue
if (!isNavPath(link.to)) continue
seen.add(link.id)
const clean = { id: link.id, label: link.label.trim(), to: link.to }
if (typeof link.order === 'number' && Number.isFinite(link.order)) clean.order = link.order
if (typeof link.section === 'string' && knownSections.has(link.section)) clean.section = link.section
out.push(clean)
}
return out
}
function resolveItems(items, key, knownSections) {
const out = {}
if (!value || typeof value !== 'object' || Array.isArray(value)) return out
const unhideable = UNHIDEABLE[key] || []
for (const [to, entry] of Object.entries(value)) {
for (const [to, entry] of Object.entries(items)) {
if (!isNavPath(to) || !entry || typeof entry !== 'object' || Array.isArray(entry)) continue
const clean = {}
// A label that is only whitespace is not a label — it would render an
@@ -140,6 +324,10 @@ function resolveNavOverrides(value, key = 'nav') {
if (typeof entry.group === 'string' && entry.group.trim() && entry.group.length <= MAX_GROUP) {
clean.group = entry.group.trim()
}
// Only a section that survived resolution: an item pointing at a deleted or
// malformed one belongs at the top level, visible, rather than inside a
// dropdown that no longer exists.
if (typeof entry.section === 'string' && knownSections.has(entry.section)) clean.section = entry.section
if (Object.keys(clean).length > 0) out[to] = clean
}
return out

View File

@@ -3463,7 +3463,7 @@
"Admin · Settings"
],
"summary": "Update site settings (admin only)",
"description": "Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides carry only label/order/hidden/group; whether a key names a route the nav declares is settled client-side at merge time.",
"description": "Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.",
"responses": {
"200": {
"description": "Updated settings",

View File

@@ -169,3 +169,121 @@ test('a non-object resolves to {} rather than throwing', () => {
test('NAV_KEYS names the three rows the controller validates', () => {
assert.deepEqual(NAV_KEYS, ['nav_public', 'nav_admin', 'nav_player'])
})
// ── Sections and added links (phase 10) ───────────────────────────────────
//
// The public header may carry admin-created dropdown sections and links the
// admin authored. The invariant that has to survive is structural: `items` may
// only key routes the code declares, so it can never introduce one, while
// `links` is the one place an arbitrary path may be named — and is therefore
// the one place the path rule is applied.
const WRAPPED = {
items: { '/site/champs': { section: 'sec_abcd', order: 0 } },
sections: [{ id: 'sec_abcd', label: 'The World', order: 3 }],
links: [{ id: 'lnk_wxyz', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_abcd', order: 1 }],
}
test('a bare items map is still valid and still stored as-is', () => {
// Phases 6-8 wrote this shape, and a nav that does not use sections keeps it.
assert.equal(validateNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public').ok, true)
assert.deepEqual(resolveNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public'), {
'/site/news': { label: 'N' },
})
})
test('a wrapped value round-trips with its sections and links', () => {
assert.equal(validateNavOverrides(WRAPPED, 'nav_public').ok, true)
assert.deepEqual(resolveNavOverrides(WRAPPED, 'nav_public'), WRAPPED)
})
test('an added link must point at this site', () => {
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', 'wiki/guide', '/a b', '/a"b']) {
const check = validateNavOverrides(
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Bad', to }] },
'nav_public',
)
assert.equal(check.ok, false, `${to} should be refused`)
assert.match(check.message, /must be a path on this site/)
}
})
test('a link to a path that happens to be gated is allowed — the page is the gate', () => {
// An added link carries no roles/feature of its own and does not need one: the
// route behind it enforces its own access, exactly as typing the URL would.
const check = validateNavOverrides(
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Admin', to: '/admin/users' }] },
'nav_public',
)
assert.equal(check.ok, true)
})
test('section and link ids are constrained, and duplicates refused', () => {
const bad = [
[{ sections: [{ id: 'nope', label: 'X' }] }, /invalid id/],
[{ sections: [{ id: 'sec_AB', label: 'X' }] }, /invalid id/],
[{ sections: [{ id: 'sec_abcd', label: '' }] }, /label must be text/],
[{ sections: [{ id: 'sec_abcd', label: 'A' }, { id: 'sec_abcd', label: 'B' }] }, /duplicate id/],
[{ links: [{ id: 'sec_abcd', label: 'X', to: '/x' }] }, /invalid id/],
[{ links: [{ id: 'lnk_abcd', label: 'A', to: '/a' }, { id: 'lnk_abcd', label: 'B', to: '/b' }] }, /duplicate id/],
]
for (const [extra, pattern] of bad) {
const check = validateNavOverrides({ items: {}, ...extra }, 'nav_public')
assert.equal(check.ok, false, JSON.stringify(extra))
assert.match(check.message, pattern)
}
})
test('sections and links are bounded', () => {
const sections = Array.from({ length: 13 }, (_, i) => ({ id: `sec_a${String(i).padStart(3, '0')}`, label: 'S' }))
assert.match(validateNavOverrides({ items: {}, sections }, 'nav_public').message, /at most 12 sections/)
const links = Array.from({ length: 41 }, (_, i) => ({ id: `lnk_a${String(i).padStart(3, '0')}`, label: 'L', to: '/x' }))
assert.match(validateNavOverrides({ items: {}, links }, 'nav_public').message, /at most 40 added links/)
})
test('sections and links are dropped for the navs that cannot render them', () => {
// The admin sidebar has its own coded sections and the player portal is three
// flat rows; only the public header supports this.
for (const key of ['nav_admin', 'nav_player']) {
const out = resolveNavOverrides(WRAPPED, key)
assert.equal(out.sections, undefined, key)
assert.equal(out.links, undefined, key)
// The item survives, minus the section it can no longer belong to.
assert.deepEqual(out, { '/site/champs': { order: 0 } })
}
})
test('an item or link naming a section that does not exist falls to the top level', () => {
const out = resolveNavOverrides(
{
items: { '/site/champs': { section: 'sec_gone', order: 2 } },
sections: [{ id: 'sec_abcd', label: 'Real' }],
links: [{ id: 'lnk_wxyz', label: 'L', to: '/x', section: 'sec_gone' }],
},
'nav_public',
)
assert.equal(out.items['/site/champs'].section, undefined)
assert.equal(out.links[0].section, undefined)
})
test('an unusable section or link is dropped, its neighbours kept', () => {
const out = resolveNavOverrides(
{
items: {},
sections: [{ id: 'sec_abcd', label: 'Keep' }, { id: 'bad', label: 'Drop' }],
links: [
{ id: 'lnk_aaaa', label: 'Keep', to: '/keep' },
{ id: 'lnk_bbbb', label: 'Drop', to: 'https://evil.example' },
],
},
'nav_public',
)
assert.deepEqual(out.sections.map((s) => s.label), ['Keep'])
assert.deepEqual(out.links.map((l) => l.label), ['Keep'])
})
test('a wrapper that resolves to nothing usable comes back empty', () => {
// The caller deletes the row rather than storing a wrapper that says nothing.
assert.deepEqual(resolveNavOverrides({ items: {}, sections: [], links: [] }, 'nav_public'), {})
assert.deepEqual(resolveNavOverrides({ items: 'nope' }, 'nav_public'), {})
})