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:
@@ -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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
import BrandAssetsPanel from './BrandAssetsPanel.jsx'
|
||||
|
||||
// Admin · Appearance — the theme and brand-asset halves of
|
||||
@@ -82,27 +83,13 @@ export default function AppearanceAdmin() {
|
||||
.then(([opts, all]) => {
|
||||
if (!active) return
|
||||
setOptions(opts)
|
||||
// The stored value is a JSON string (settings.value is TEXT). Malformed
|
||||
// reads as absent, exactly as the server treats it — the form then shows
|
||||
// the shipped default rather than an error.
|
||||
let parsed = null
|
||||
try {
|
||||
const raw = all.theme_visual
|
||||
parsed = raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
// The stored values are JSON strings (settings.value is TEXT), and a
|
||||
// malformed one reads as absent exactly as the server treats it — the
|
||||
// form then shows the shipped default rather than an error.
|
||||
const parsed = parseJsonSetting(all.theme_visual)
|
||||
setStored(Boolean(all.theme_visual))
|
||||
// Same fail-safe parse as the theme: a malformed row reads as absent, so
|
||||
// the panel shows the env defaults rather than an error.
|
||||
let parsedAssets = null
|
||||
try {
|
||||
parsedAssets = all.brand_assets ? JSON.parse(all.brand_assets) : null
|
||||
} catch {
|
||||
parsedAssets = null
|
||||
}
|
||||
setAssets(parsedAssets && typeof parsedAssets === 'object' && !Array.isArray(parsedAssets) ? parsedAssets : {})
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
setAssets(parseJsonSetting(all.brand_assets) || {})
|
||||
if (parsed) {
|
||||
setPreset(parsed.preset || 'runic-gateway')
|
||||
setCustom({
|
||||
colors: parsed.custom?.colors || {},
|
||||
|
||||
465
client/src/routes/admin/views/NavEditor.jsx
Normal file
465
client/src/routes/admin/views/NavEditor.jsx
Normal file
@@ -0,0 +1,465 @@
|
||||
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 { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
|
||||
import { buildNavRows, buildNavOverrides } from '../../../lib/navOverrides.js'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.to })
|
||||
const renamed = 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}
|
||||
maxLength={64}
|
||||
onChange={(e) => onChange({ ...row, label: e.target.value })}
|
||||
aria-label={`Label for ${row.defaultLabel}`}
|
||||
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>
|
||||
)}
|
||||
{groupTitles.length > 0 && (
|
||||
<select
|
||||
className="select"
|
||||
value={currentGroup ?? ''}
|
||||
onChange={(e) => onMoveGroup(row.to, e.target.value || null)}
|
||||
aria-label={`Section for ${row.defaultLabel}`}
|
||||
style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
|
||||
>
|
||||
{/* "(no section)" is offered only to a row that is coded into one of
|
||||
the untitled groups (Dashboard, Account) — for anything else it is
|
||||
a move that cannot be stored: an override names an existing titled
|
||||
section or nothing at all (§6.4), so picking it would silently do
|
||||
nothing. Such a row can still move OUT and back, which clears the
|
||||
override rather than storing a null group. */}
|
||||
{baseGroup === null && <option value="">(no section)</option>}
|
||||
{groupTitles.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<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 shardFeatures = useShardFeatures()
|
||||
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 palette: each base nav, filtered to what THIS admin can see (§8.1). The
|
||||
// public nav's gates are the shard-feature ones; the admin nav's are roles.
|
||||
// The player portal has no gates at all.
|
||||
const palettes = useMemo(
|
||||
() => ({
|
||||
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
|
||||
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
|
||||
(g) => g.items.length > 0,
|
||||
),
|
||||
nav_player: PLAYER_NAV,
|
||||
}),
|
||||
[shardFeatures, 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])
|
||||
next[key] = { 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 groupTitles = 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(
|
||||
(Array.isArray(palettes[tab]) && palettes[tab][0]?.items
|
||||
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
|
||||
: []),
|
||||
)
|
||||
|
||||
function mutate(updater) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
|
||||
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 = buildNavOverrides(current.groups, palettes[tab], current.stored)
|
||||
const empty = Object.keys(overrides).length === 0
|
||||
// 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]: { 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 ───────────────────────────────────────────────── */}
|
||||
<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}
|
||||
row={row}
|
||||
groupTitles={groupTitles}
|
||||
currentGroup={group.title ?? null}
|
||||
baseGroup={baseGroups.get(row.to) ?? null}
|
||||
onChange={onRowChange}
|
||||
onMoveGroup={onMoveGroup}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo } 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'
|
||||
|
||||
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
||||
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
||||
@@ -31,7 +34,11 @@ const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
|
||||
const NAV = [
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No row
|
||||
// carries a gate — every player sees all three — so the merged result is what
|
||||
// renders, with no filter after it.
|
||||
export const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
@@ -61,6 +68,8 @@ const navBtnBase = {
|
||||
export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
@@ -100,7 +109,7 @@ export default function PlayerPortalLayout() {
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{NAV.map((n) => (
|
||||
{nav.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
|
||||
Reference in New Issue
Block a user