Files
website/client/src/lib/navOverrides.js
wtclaude b517d7b2df 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>
2026-08-08 00:42:19 -05:00

503 lines
22 KiB
JavaScript

// Apply an admin's stored navigation overrides to a hardcoded NAV array.
//
// The three navs (public header, admin sidebar, player portal) stay declared in
// code; this layer only reorders, relabels and hides what is already there.
// See docs/website/THEMING_AND_NAV.md §7.
//
// **This is presentation, never authorization.** The override can carry
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
// *after* this merge, unchanged, and remain the actual boundary. An override
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
// whose role check fails: hiding is subtractive here, never additive.
//
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
// label, a group that does not exist — is ignored rather than rejected, so a
// stale or hand-edited settings row degrades to the code default instead of
// rendering a broken nav.
// Two shapes are supported, because two exist:
// flat [{ to, label, ... }] — public header, player portal
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
function isGrouped(nav) {
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
}
// A stored override entry is usable only field by field: a bad `label` must not
// discard a good `order` alongside it.
function cleanEntry(raw, groupTitles) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const out = {}
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
if (raw.hidden === true) out.hidden = true
// `group` may only name a section the base nav already declares. Anything else
// — a renamed group, a typo, an invented category — is dropped, so an item can
// never land in a header that does not exist.
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
return out
}
// Sort by effective order, where an item the admin never reordered keeps its
// index in the base array as its key. Two tie-breaks, in order: an explicit
// order beats a coincidental index (the admin said "first", so first), and two
// explicit orders stay in code order (the sort is stable).
//
// In practice the editor writes an order for every item in a list, the way
// drag-and-drop reordering does, so ties are the stale-row case rather than the
// normal one. They still have to resolve predictably.
function byOrder(items) {
return items
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
.map(({ item }) => {
const { __order, ...rest } = item
return rest
})
}
// Apply label/hidden/order to one flat list, with the sort key parked on
// `__order` for byOrder to consume.
//
// `keepHidden` is what the admin editor needs and the site must not have: the
// editor has to render a hidden row in its right place so it can be un-hidden,
// while a layout must simply not render it. Same merge either way, so the two
// can never disagree about where an item sits.
function mergeItems(items, entries, keepHidden = false) {
const out = []
for (const item of items) {
const o = entries.get(item.to)
if (o?.hidden && !keepHidden) continue
// Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
// survive verbatim — the override only ever lands on `label`.
out.push({
...item,
...(o?.label ? { label: o.label } : {}),
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
__order: o?.order,
})
}
return out
}
// The stored overrides, cleaned and keyed, plus the group titles the base nav
// declares. Shared by the merge and the editor so both read a row the same way.
function readOverrides(baseNav, overrides, grouped) {
const groupTitles = new Set(
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
)
const entries = new Map()
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles }
// Keyed by `to`, and only for a `to` the base nav actually declares. An
// override for a route that no longer exists is dropped here, so deleting a
// route in code can never leave a dangling override that does something
// unexpected later.
const known = new Set(
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
)
for (const [to, raw] of Object.entries(overrides)) {
if (!known.has(to)) continue
const entry = cleanEntry(raw, groupTitles)
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
}
return { entries, groupTitles }
}
// Move items whose override names a different existing section. Groups keep
// their coded order — only membership and within-group order move.
function regroup(baseNav, entries) {
const moved = new Map() // destination title → items pulled in from elsewhere
const kept = baseNav.map((g) => {
const items = []
for (const item of g.items) {
const o = entries.get(item.to)
if (o?.group && o.group !== g.title) {
if (!moved.has(o.group)) moved.set(o.group, [])
moved.get(o.group).push(item)
continue
}
items.push(item)
}
return { ...g, items }
})
return { kept, moved }
}
/**
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
* `roles`, `feature`, `icon` and `end`
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
* null when the admin never touched this nav
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
* is nothing to apply
*/
export function applyNavOverrides(baseNav, overrides) {
if (!Array.isArray(baseNav)) return []
// The untouched path, and the one that matters most: no row, a malformed row,
// or a row with nothing usable in it all render the nav exactly as coded.
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav
const grouped = isGrouped(baseNav)
const { entries } = readOverrides(baseNav, overrides, grouped)
if (entries.size === 0) return baseNav
if (!grouped) return byOrder(mergeItems(baseNav, entries))
// Grouped: an item may also be moved into another *existing* titled section.
const { kept, moved } = regroup(baseNav, entries)
return kept
.map((g) => ({
...g,
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
}))
// A group whose every item was hidden must not leave an orphaned header.
// AdminLayout drops empty groups again after its own role filter; doing it
// here too keeps the util correct on its own.
.filter((g) => g.items.length > 0)
}
// ── The admin editor's round trip ────────────────────────────────────────
//
// Two functions, inverse to each other, kept in this file rather than beside the
// editor screen so the thing that *writes* an override and the thing that
// *applies* one can never drift: the rows the admin drags are produced by the
// same merge the site renders, hidden ones included.
/**
* The base nav plus its stored overrides, as editable rows — always in the
* grouped shape, so one editor handles both navs.
*
* Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so
* they can be un-hidden) and keeps empty groups (so something can be moved back
* into one). Each row carries `defaultLabel`, which is what "reset this label"
* restores and what the input shows as its placeholder.
*
* @param {Array} baseNav the hardcoded nav, flat or grouped
* @param {object|null} overrides the parsed settings JSON
* @returns {Array<{title: string|null, items: Array}>}
*/
export function buildNavRows(baseNav, overrides) {
if (!Array.isArray(baseNav) || baseNav.length === 0) return []
const grouped = isGrouped(baseNav)
const { entries } = readOverrides(baseNav, overrides, grouped)
if (!grouped) {
return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }]
}
const { kept, moved } = regroup(baseNav, entries)
return kept.map((g) => ({
...g,
title: g.title ?? null,
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)),
}))
}
// Did the admin actually move anything? Comparing the edited sequence with the
// coded one is what decides whether orders are written at all: an admin who only
// renamed an item should not pin the position of every other one, or a route
// added in code later would land in an arbitrary place.
//
// The base side is restricted to the rows the editor is actually holding: §8.1
// filters the palette to what this admin can themselves see, and an item that
// their role or a shard feature kept off the screen is not a reorder.
function orderMatchesBase(groups, baseNav) {
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
const base = isGrouped(baseNav)
? baseNav.map((g) => ({ title: g.title ?? null, items: g.items }))
: [{ title: null, items: baseNav }]
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
const a = flatten(groups)
const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) })))
return a.length === b.length && a.every((v, i) => v === b[i])
}
/**
* The rows the admin has been editing, back as an overrides object to store.
* Only differences from the code default are written — a field that matches the
* default is absent, so the row stays a small statement of intent rather than a
* snapshot of the nav.
*
* @param {Array} groups the editor's groups, in their current order
* @param {Array} baseNav the hardcoded nav these rows came from
* @param {object|null} stored the overrides as loaded, so entries for items
* this admin could not see (role- or feature-gated out of their palette) are
* carried through rather than silently dropped on save
* @returns {object} the overrides to store — `{}` when nothing differs
*/
export function buildNavOverrides(groups, baseNav, stored = null) {
if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {}
const grouped = isGrouped(baseNav)
const baseItems = new Map(
(grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map(
([item, title]) => [item.to, { label: item.label, group: title }],
),
)
const out = {}
// Carry through what this admin's palette never showed them. An entry for a
// `to` the base nav no longer declares is NOT carried: dropping it is the
// cleanup, and applyNavOverrides ignores it anyway.
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
for (const [to, entry] of Object.entries(stored)) {
if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry
}
}
const writeOrder = !orderMatchesBase(groups, baseNav)
for (const group of groups) {
group.items.forEach((row, index) => {
const base = baseItems.get(row.to)
if (!base) return
const entry = {}
const label = typeof row.label === 'string' ? row.label.trim() : ''
if (label && label !== base.label) entry.label = label
if (row.hidden === true) entry.hidden = true
if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title
if (writeOrder) entry.order = index
if (Object.keys(entry).length > 0) out[row.to] = entry
})
}
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