feat(theming): wire the three navs and add the admin nav builder

Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin
sidebar and the player portal now read their override row, and /admin/navigation
writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a
row into another existing section.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-08 00:02:33 -05:00
parent 42a403ad2e
commit 32a3ff104a
19 changed files with 1499 additions and 79 deletions

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
// One shared frame keeps them terse; each item just supplies its path(s).
@@ -39,6 +41,7 @@ const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><p
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
@@ -46,7 +49,11 @@ const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2
// (when present) matches server-side enforcement so the sidebar never shows a
// link that would 403; an item without `roles` is visible to everyone.
// Moderators are further confined to just their section + account (see below).
const NAV = [
//
// Exported because Admin -> Navigation edits this list. It stays declared here:
// the editor may relabel, reorder, hide and regroup, and `roles` is never its to
// touch (§7) — navItemVisibleTo below is the filter that still decides.
export const NAV = [
{
items: [
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
@@ -77,6 +84,7 @@ const NAV = [
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
@@ -96,6 +104,35 @@ const NAV = [
const COLLAPSE_KEY = 'admin.nav.collapsed'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
// The one row an override may never hide: the nav editor itself, which is the
// only screen that can un-hide anything. The write path already refuses it
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
// this is the third guard, and the one that also covers a row edited straight
// in the database. Cheap, and it makes "cannot be hidden" true without
// qualification.
const UNHIDEABLE = '/admin/navigation'
function keepEditorReachable(overrides) {
const entry = overrides?.[UNHIDEABLE]
if (!entry || entry.hidden !== true) return overrides
const { hidden, ...rest } = entry
return { ...overrides, [UNHIDEABLE]: rest }
}
// Who may see a sidebar row. The single authority for that question: the layout
// applies it after the override merge (overrides are presentation, this is the
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
// admin is never offered a row they cannot themselves see (§8.1).
export function navItemVisibleTo(item, role) {
if (item.roles && !item.roles.includes(role)) return false
if (role === 'moderator') return MOD_PATHS.includes(item.to)
return true
}
const TITLES = {
'/admin': 'Dashboard',
'/admin/posts': 'Posts',
@@ -108,6 +145,7 @@ const TITLES = {
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
@@ -145,6 +183,7 @@ const navBtnBase = {
export default function AdminLayout() {
const { user, logout } = useAuth()
const { mode, siteTitle } = useSite()
const navOverrides = useNavOverrides()
const navigate = useNavigate()
const location = useLocation()
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
@@ -152,20 +191,21 @@ export default function AdminLayout() {
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const isModerator = user?.role === 'moderator'
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
const visible = (item) => {
if (item.roles && !item.roles.includes(user?.role)) return false
if (isModerator) return MOD_PATHS.includes(item.to)
return true
}
// Drop items the current role can't see, then drop any now-empty group so an
// empty category header never renders.
const navGroups = NAV
.map((g) => ({ ...g, items: g.items.filter(visible) }))
.filter((g) => g.items.length > 0)
// An admin may relabel, reorder, hide and regroup these rows from Admin →
// Navigation. The merge runs FIRST and the role filter after it, so the filter
// stays the boundary: an override cannot show a moderator a row their role
// gate hides, whatever it says. With no stored row applyNavOverrides returns
// NAV itself and this is exactly the code that ran before the feature.
const navGroups = useMemo(
() =>
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
// Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0),
[navOverrides.nav_admin, user?.role],
)
// Accordion: track which titled categories are collapsed. Persist across
// reloads; default all-open. The group holding the active route auto-opens.