Files
website/client/src/routes/admin/views/NavEditor.jsx
wtclaude a45a3d120a
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 1m34s
PR Checks / client-build (pull_request) Successful in 8m58s
feat(modules): interleave module nav, derive moderator confinement
Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md 2.7 - the nav half PR 7
deferred, plus the two seams 1.4 and 1.5 asked for.

withModuleNav (client/src/modules/nav.js) merges an installed module's rows
into core's three navs BEFORE the admin-override merge, and that ordering is
the design. applyNavOverrides and buildPublicNav are keyed by `to` and drop
any key their base array does not declare, so rows appended after the merge
would be unorderable, unrelabellable and unhideable in Admin - Navigation.
Today's UO rows are all three of those things, so appending would make the
extraction a visible regression for anyone who has ever edited their nav.
Merging first means a module row is an ordinary row downstream: nothing in
navOverrides.js, NavEditor.jsx or the layouts knows a module exists.

MOD_PATHS is gone. Moderator visibility and the redirect that confines a
moderator both derive from each row's own `roles`, in the new plain-JS
lib/adminNav.js (plain so the DOM-less runner can reach it). Two rows move,
both toward what the server already permitted: Dashboard, whose roles had
always named moderator, and My Characters, which is ungated self-service.

That also fixes a defect predating the module system. The redirect was a
THIRD hardcoded list - three path prefixes against MOD_PATHS' five paths -
and they disagreed about /admin/houses, so a moderator who clicked Houses in
their own sidebar was bounced back to Moderation. The derived allow-list is
computed from the BASE nav, never the override-merged one: an override is
presentation and must not move an authorization boundary either way.

The feature seam (modules/features.jsx + modules/featureGate.js) resolves a
row's `feature` against the provider its OWN module registered, so the
namespace comes from the registration and no string carries a parsed prefix.
Core registers useShardFlags under the owner id `core` - the client twin of
registries.registerCore() - so the ten shard-gated header rows already run
through the seam and Phase 3 deletes a registration instead of rewriting
SiteHeader. Every unknown fails open: no provider, a null answer while a
fetch is in flight, or a junk return all show the link, because the server is
the gate and hiding a page from someone entitled to it is the worse mistake.

933 server tests (unchanged - this PR is client-only), 160 client tests
(+37). routes.manifest.json unchanged at 230 routes; the OpenAPI spec
regenerates byte-identical.

Re-ran the MODULE_API.md 7.7 browser smoke, since this is the seam that rule
exists for. A throwaway module registering nav in all three areas and a
provider granting one flag and withholding another: the row lands inside
core's Moderation group rather than an appended block, the withheld row does
not render, a moderator reaches both /admin/houses and the module's admin
page, and an admin can relabel a module row and have it persist and apply.
Zero CSP reports, zero console errors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:42:02 -05:00

561 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { withModuleNav } from '../../../modules/nav.js'
import { useFeatureGate } from '../../../modules/features.jsx'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js'
import { refreshNavOverrides } from '../../../lib/useNavOverrides.js'
import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx'
import { NAV as ADMIN_NAV, navItemVisibleTo } from '../AdminLayout.jsx'
import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
// Admin · Navigation — phases 6-8 of docs/website/THEMING_AND_NAV.md.
//
// The three navs stay declared in code, each in the component that renders it;
// this screen writes an override *layer* over them (§7). It can relabel,
// reorder, hide and — on the admin sidebar — move a row into another existing
// section, and nothing else. It cannot introduce a route and it cannot touch a
// `roles` or `feature` gate, so the filters in the layouts still decide who sees
// what, and they run after the merge.
//
// Three things shape the screen:
//
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
// the base array run through their role and this shard's feature gates. An
// admin cannot drag in, and so can never accidentally advertise, something
// they cannot see themselves. An override on a row they cannot see is
// carried through their save untouched rather than quietly reset.
// • The rows come from the same merge the site renders (buildNavRows), hidden
// ones included, so the editor cannot show an order the nav does not use.
// • Saving writes a settings row; "reset" DELETES it. Absence of the row is
// what selects the coded default, so reset cannot store a copy of it — and a
// save whose result is empty deletes the row for the same reason (§4.1).
// The nav editor's own row. Hiding it would remove the only screen that can
// un-hide it, so its eye toggle is disabled here and the server drops `hidden`
// on it as well (server/src/utils/navOverrides.js) — a hand-written row cannot
// do what the UI refuses.
const SELF = '/admin/navigation'
const TABS = [
{ key: 'nav_public', label: 'Public site', hint: 'The header on every public page.' },
{ key: 'nav_admin', label: 'Admin', hint: 'This sidebar. Rows can also move between sections.' },
{ key: 'nav_player', label: 'Player portal', hint: 'The sidebar a signed-in player sees.' },
]
function DragHandle({ attributes, listeners, disabled }) {
return (
<button
type="button"
className="sans"
aria-label="Reorder"
disabled={disabled}
{...attributes}
{...listeners}
style={{
border: 'none',
background: 'transparent',
color: 'var(--dim)',
cursor: disabled ? 'default' : '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>
)
}
function EyeIcon({ off }) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
{off && <path d="M3 3l18 18" />}
</svg>
)
}
/**
* 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 (
<li
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 10px',
borderRadius: 'var(--radius-input)',
border: '1px solid var(--line)',
background: isDragging ? 'var(--blue)' : 'var(--panel-flat)',
opacity: row.hidden ? 0.55 : 1,
listStyle: 'none',
}}
>
<DragHandle attributes={attributes} listeners={listeners} />
<input
className="input"
value={row.label}
placeholder={row.defaultLabel || row.to}
maxLength={64}
onChange={(e) => onChange({ ...row, label: e.target.value })}
aria-label={`Label for ${row.defaultLabel || row.to}`}
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem' }}
/>
{/* The route, for orientation — it is what the override is keyed by. Fixed
and truncating rather than flexible: /admin/moderation/appeals would
otherwise wrap and squeeze the label input it sits beside. */}
<code
className="sans dim"
title={row.to}
style={{
flex: '0 0 auto',
width: 130,
fontSize: '0.7rem',
opacity: 0.75,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'right',
}}
>
{row.to}
</code>
{renamed && (
<button
type="button"
className="sans"
title="Use the coded label again"
onClick={() => onChange({ ...row, label: row.defaultLabel })}
style={{ border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: '0.72rem', cursor: 'pointer', padding: 0 }}
>
reset
</button>
)}
{destinations && destinations.length > 0 && (
<select
className="select"
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' }}
>
{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"
disabled={locked}
title={
locked
? 'This screen is the only way back — it cannot be hidden'
: row.hidden
? 'Currently hidden. Show it again'
: 'Hide from this nav'
}
aria-pressed={row.hidden}
onClick={() => onChange({ ...row, hidden: !row.hidden })}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: locked ? 'var(--dim)' : row.hidden ? 'var(--accent)' : 'var(--muted)',
cursor: locked ? 'not-allowed' : 'pointer',
padding: '4px 6px',
display: 'flex',
opacity: locked ? 0.5 : 1,
}}
>
<EyeIcon off={row.hidden} />
</button>
)}
</li>
)
}
export default function NavEditor() {
const { user } = useAuth()
const { refresh: refreshSite } = useSite()
const isVisible = useFeatureGate()
const [tab, setTab] = useState('nav_public')
// Per nav: the editable groups, the overrides as loaded (so a row this admin
// cannot see survives their save), and whether a settings row exists at all.
const [state, setState] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState('')
const [dirty, setDirty] = useState({})
// The 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.
//
// Each nav is the coded array with every installed module's rows already
// interleaved (modules/nav.js) — the same array the layout renders, which is
// what makes a module row editable here at all: the override merge is keyed by
// `to` and drops a key the base it is handed does not declare, so a nav built
// from core alone would silently discard every stored override on a module row
// the moment it was saved.
const fullNavs = useMemo(
() => ({
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
nav_player: withModuleNav(PLAYER_NAV, 'player'),
}),
[],
)
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
// gates, and neither is core's own opinion any more — `roles` on a row, and
// the owning module's answer for a row that names a `feature`.
const palettes = useMemo(
() => ({
nav_public: fullNavs.nav_public.filter(isVisible),
nav_admin: fullNavs.nav_admin
.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
.filter((g) => g.items.length > 0),
nav_player: fullNavs.nav_player.filter(isVisible),
}),
[fullNavs, isVisible, user?.role],
)
useEffect(() => {
let active = true
api.admin
.getSettings()
.then((all) => {
if (!active) return
const next = {}
for (const { key } of TABS) {
const stored = parseJsonSetting(all[key])
// The public header is a tree (sections are entries in the top-level
// order); the other two are the fixed-frame grouped/flat shape.
next[key] =
key === 'nav_public'
? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) }
: { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
}
setState(next)
})
.catch(() => active && setError('Could not load the navigation settings.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
// Loaded once; the palettes settle before the fetch resolves in practice, and
// re-running on a feature flip would discard the admin's unsaved edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
if (loading) return <Loading />
if (error && !state) return <ErrorState message={error} />
const current = state[tab]
const isPublic = tab === 'nav_public'
const groupTitles = isPublic ? [] : current.groups.map((g) => g.title).filter(Boolean)
// Where each row is declared in code, so the section dropdown can offer only
// the destinations an override is able to express.
const baseGroups = new Map(
(!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
: []),
)
// The admin sidebar can only move a row between the four coded sections, and
// "(no section)" only for a row coded into an untitled one — for anything else
// it is a move an override cannot express (§6.4), so offering it would
// silently do nothing.
const groupDestinations = (baseGroup) => [
...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []),
...groupTitles.map((t) => ({ value: t, label: t })),
]
function mutate(updater) {
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
setDirty((d) => ({ ...d, [tab]: true }))
setSaved('')
}
function setTree(tree) {
setState((s) => ({ ...s, [tab]: { ...s[tab], tree } }))
setDirty((d) => ({ ...d, [tab]: true }))
setSaved('')
}
const onRowChange = (next) =>
mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) })))
// Sections change by dropdown, not by dragging: a drag that could land in
// another list is a lot of interaction surface for something an admin does
// once, and this keeps every drag a simple reorder. The row goes to the end of
// its new section, where it is visible and can then be dragged into place.
const onMoveGroup = (to, title) =>
mutate((groups) => {
const moving = groups.flatMap((g) => g.items).find((i) => i.to === to)
if (!moving) return groups
return groups.map((g) => {
if ((g.title ?? null) === title) return { ...g, items: [...g.items.filter((i) => i.to !== to), moving] }
return { ...g, items: g.items.filter((i) => i.to !== to) }
})
})
const onDragEnd = (groupIndex) => (event) => {
const { active, over } = event
if (!over || active.id === over.id) return
mutate((groups) =>
groups.map((g, i) => {
if (i !== groupIndex) return g
const from = g.items.findIndex((it) => it.to === active.id)
const to = g.items.findIndex((it) => it.to === over.id)
if (from < 0 || to < 0) return g
return { ...g, items: arrayMove(g.items, from, to) }
}),
)
}
// Push a save into whatever is rendering that nav right now, so the admin sees
// what they just did: the header re-reads the public settings, the two
// authenticated sidebars re-read /settings/nav.
async function propagate(key) {
if (key === 'nav_public') await refreshSite()
else await refreshNavOverrides()
}
async function save() {
setBusy(true)
setError('')
try {
const overrides = isPublic
? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored)
: buildNavOverrides(current.groups, fullNavs[tab], current.stored)
// A wrapper with an empty `items` and no sections/links says nothing
// either, so "empty" is about the whole value, not just its key count.
const empty =
Object.keys(overrides).length === 0 ||
(overrides.items !== undefined &&
Object.keys(overrides.items).length === 0 &&
!overrides.sections?.length &&
!overrides.links?.length)
// Nothing differs from the code default, so there is nothing to store —
// and a row that says nothing would still read as "this nav was
// customised". Delete it instead (§2, §4.1).
if (empty) await api.admin.resetSetting(tab)
else await api.admin.updateSettings({ [tab]: overrides })
setState((s) => ({
...s,
[tab]: { ...s[tab], stored: empty ? null : overrides, hasRow: !empty },
}))
setDirty((d) => ({ ...d, [tab]: false }))
setSaved(tab)
await propagate(tab)
} catch (err) {
setError(err.message || 'Could not save this navigation.')
} finally {
setBusy(false)
}
}
async function resetNav() {
setBusy(true)
setError('')
try {
await api.admin.resetSetting(tab)
setState((s) => ({
...s,
[tab]: isPublic
? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) }
: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) },
}))
setDirty((d) => ({ ...d, [tab]: false }))
setSaved('')
await propagate(tab)
} catch (err) {
setError(err.message || 'Could not reset this navigation.')
} finally {
setBusy(false)
}
}
const activeTab = TABS.find((t) => t.key === tab)
return (
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged this
only decides what is advertised, and it can never show anyone a link their role or this shard&rsquo;s
visibility settings would hide.
</p>
{/* ── Tabs ───────────────────────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{TABS.map((t) => (
<button
key={t.key}
type="button"
className="sans"
onClick={() => {
setTab(t.key)
setSaved('')
}}
aria-pressed={tab === t.key}
style={{
padding: '8px 14px',
borderRadius: 'var(--radius-input)',
border: `1px solid ${tab === t.key ? 'var(--accent)' : 'var(--line)'}`,
background: tab === t.key ? 'var(--blue)' : 'transparent',
color: tab === t.key ? 'var(--ink)' : 'var(--muted)',
cursor: 'pointer',
fontSize: '0.86rem',
}}
>
{t.label}
{dirty[t.key] && <span style={{ color: 'var(--accent)' }}> </span>}
</button>
))}
</div>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{activeTab.hint}{' '}
{current.hasRow
? 'This nav has saved overrides.'
: 'This nav has never been customised, so it renders exactly as coded.'}
</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}`}>
{group.title && <span className="field-label">{group.title}</span>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(groupIndex)}>
<SortableContext items={group.items.map((i) => i.to)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '8px 0 0', padding: 0 }}>
{group.items.map((row) => (
<Row
key={row.to}
id={row.to}
row={row}
destinations={groupTitles.length > 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null}
destination={group.title ?? ''}
onDestination={(value) => onMoveGroup(row.to, value)}
onChange={onRowChange}
/>
))}
{group.items.length === 0 && (
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '6px 2px' }}>
Empty this section is not rendered until something is moved into it.
</li>
)}
</ul>
</SortableContext>
</DndContext>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save navigation'}
</button>
<button
onClick={resetNav}
disabled={busy || !current.hasRow}
className="pill"
title={current.hasRow ? 'Delete the saved overrides for this nav' : 'Nothing to reset'}
>
Reset to default
</button>
{saved === tab && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard
Visibility keeps whatever it was already set to.
</p>
</section>
)
}