From b517d7b2df791c2ca43336397189a31d0683d4a2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 8 Aug 2026 00:42:19 -0500 Subject: [PATCH 1/2] feat(theming): dropdown sections and added links in the public header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/components/NavDropdown.jsx | 150 +++++++++ client/src/components/SiteHeader.jsx | 30 +- client/src/lib/navOverrides.js | 236 +++++++++++++ client/src/routes/admin/views/NavEditor.jsx | 140 ++++++-- .../src/routes/admin/views/PublicNavTree.jsx | 310 ++++++++++++++++++ client/test/navOverrides.test.js | 196 ++++++++++- server/src/router/v1/admin/settings.router.js | 2 +- server/src/utils/navOverrides.js | 202 +++++++++++- server/swagger/swagger-output.json | 2 +- server/test/navOverrides.test.js | 118 +++++++ 10 files changed, 1335 insertions(+), 51 deletions(-) create mode 100644 client/src/components/NavDropdown.jsx create mode 100644 client/src/routes/admin/views/PublicNavTree.jsx diff --git a/client/src/components/NavDropdown.jsx b/client/src/components/NavDropdown.jsx new file mode 100644 index 0000000..8ea42f7 --- /dev/null +++ b/client/src/components/NavDropdown.jsx @@ -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 ( +
+ + + {open && ( +
+ {items.map((item) => ( + 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} + + ))} +
+ )} +
+ ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 84340d9..e9e142d 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -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}