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>
561 lines
22 KiB
JavaScript
561 lines
22 KiB
JavaScript
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’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>
|
||
)
|
||
}
|