From 32a3ff104a37f0275950f659974a2f55a91b4fc6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 8 Aug 2026 00:02:33 -0500 Subject: [PATCH] feat(theming): wire the three navs and add the admin nav builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/package-lock.json | 62 +++ client/package.json | 3 + client/src/App.jsx | 12 + client/src/components/SiteHeader.jsx | 25 +- client/src/lib/navOverrides.js | 202 ++++++-- client/src/lib/settingsJson.js | 32 ++ client/src/lib/useNavOverrides.js | 56 +++ client/src/routes/admin/AdminLayout.jsx | 70 ++- .../routes/admin/views/AppearanceAdmin.jsx | 27 +- client/src/routes/admin/views/NavEditor.jsx | 465 ++++++++++++++++++ .../src/routes/player/PlayerPortalLayout.jsx | 13 +- client/test/navOverrides.test.js | 149 +++++- client/test/settingsJson.test.js | 28 ++ .../src/router/v1/admin/admin.controller.js | 18 + server/src/router/v1/admin/settings.router.js | 1 + server/src/utils/navOverrides.js | 148 ++++++ server/swagger/swagger-output.json | 2 +- server/test/navOverrides.test.js | 171 +++++++ server/test/settingsTheming.test.js | 94 ++++ 19 files changed, 1499 insertions(+), 79 deletions(-) create mode 100644 client/src/lib/settingsJson.js create mode 100644 client/src/lib/useNavOverrides.js create mode 100644 client/src/routes/admin/views/NavEditor.jsx create mode 100644 client/test/settingsJson.test.js create mode 100644 server/src/utils/navOverrides.js create mode 100644 server/test/navOverrides.test.js diff --git a/client/package-lock.json b/client/package-lock.json index 7118c69..b41b713 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,9 @@ "name": "runic-gateway-client", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", "@tiptap/extension-text-align": "^2.27.2", @@ -306,6 +309,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2488,6 +2544,12 @@ "@popperjs/core": "^2.9.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", diff --git a/client/package.json b/client/package.json index 907923e..97468f9 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,9 @@ "test": "node --test" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", "@tiptap/extension-text-align": "^2.27.2", diff --git a/client/src/App.jsx b/client/src/App.jsx index 2095d3f..3cacb15 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -42,6 +42,7 @@ import PageBuilder from './routes/admin/views/PageBuilder.jsx' import WikiAdmin from './routes/admin/views/WikiAdmin.jsx' import HeroEditor from './routes/admin/views/HeroEditor.jsx' import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx' +import NavEditor from './routes/admin/views/NavEditor.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' @@ -151,6 +152,17 @@ export default function App() { } /> + {/* Same reasoning as Appearance: the nav overrides are an admin-only + settings key, so the route carries the same RoleGate as the + sidebar entry that reaches it. */} + + + + } + /> } /> Shard Visibility). They are hidden when this // viewer can't reach them, so we never render a link that would 403. The gate // itself is server-side; this is only about not advertising a dead end. -const NAV = [ +// +// Exported because Admin -> Navigation edits this list. It stays declared here, +// with this component as its owner: the editor may only relabel, reorder and +// hide what it finds, and `to`/`feature` are never its to change (§7). +export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, { label: 'Screenshots', to: '/site/screenshots' }, @@ -39,9 +46,21 @@ const linkStyle = ({ isActive }) => ({ export default function SiteHeader() { const { user, loading } = useAuth() - const { siteTitle } = useSite() + const { siteTitle, settings } = useSite() const shardFeatures = useShardFeatures() - const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)) + + // An admin may relabel, reorder and hide these entries from Admin → + // Navigation (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 + // 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)) + }, [settings.nav_public, shardFeatures]) // Where the auth entry points: staff → admin, player → portal, else sign in. let account diff --git a/client/src/lib/navOverrides.js b/client/src/lib/navOverrides.js index beb70f2..9a51ce8 100644 --- a/client/src/lib/navOverrides.js +++ b/client/src/lib/navOverrides.js @@ -57,20 +57,73 @@ function byOrder(items) { }) } -// Apply label/hidden/order to one flat list. Returns visible items only, with -// the sort key parked on `__order` for byOrder to consume. -function mergeItems(items, entries) { +// Apply label/hidden/order to one flat list, with the sort key parked on +// `__order` for byOrder to consume. +// +// `keepHidden` is what the admin editor needs and the site must not have: the +// editor has to render a hidden row in its right place so it can be un-hidden, +// while a layout must simply not render it. Same merge either way, so the two +// can never disagree about where an item sits. +function mergeItems(items, entries, keepHidden = false) { const out = [] for (const item of items) { const o = entries.get(item.to) - if (o?.hidden) continue + if (o?.hidden && !keepHidden) continue // Spread the base item first so `to`, `roles`, `feature`, `icon` and `end` // survive verbatim — the override only ever lands on `label`. - out.push({ ...item, ...(o?.label ? { label: o.label } : {}), __order: o?.order }) + out.push({ + ...item, + ...(o?.label ? { label: o.label } : {}), + ...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}), + __order: o?.order, + }) } return out } +// The stored overrides, cleaned and keyed, plus the group titles the base nav +// declares. Shared by the merge and the editor so both read a row the same way. +function readOverrides(baseNav, overrides, grouped) { + const groupTitles = new Set( + grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [], + ) + const entries = new Map() + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles } + // Keyed by `to`, and only for a `to` the base nav actually declares. An + // override for a route that no longer exists is dropped here, so deleting a + // route in code can never leave a dangling override that does something + // unexpected later. + const known = new Set( + grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to), + ) + for (const [to, raw] of Object.entries(overrides)) { + if (!known.has(to)) continue + const entry = cleanEntry(raw, groupTitles) + if (entry && Object.keys(entry).length > 0) entries.set(to, entry) + } + return { entries, groupTitles } +} + +// Move items whose override names a different existing section. Groups keep +// their coded order — only membership and within-group order move. +function regroup(baseNav, entries) { + const moved = new Map() // destination title → items pulled in from elsewhere + const kept = baseNav.map((g) => { + const items = [] + for (const item of g.items) { + const o = entries.get(item.to) + if (o?.group && o.group !== g.title) { + if (!moved.has(o.group)) moved.set(o.group, []) + moved.get(o.group).push(item) + continue + } + items.push(item) + } + return { ...g, items } + }) + return { kept, moved } +} + /** * @param {Array} baseNav the hardcoded nav — the source of truth for `to`, * `roles`, `feature`, `icon` and `end` @@ -86,43 +139,13 @@ export function applyNavOverrides(baseNav, overrides) { if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav const grouped = isGrouped(baseNav) - const groupTitles = new Set( - grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [], - ) - - // Keyed by `to`, and only for a `to` the base nav actually declares. An - // override for a route that no longer exists is dropped here, so deleting a - // route in code can never leave a dangling override that does something - // unexpected later. - const known = new Set( - grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to), - ) - const entries = new Map() - for (const [to, raw] of Object.entries(overrides)) { - if (!known.has(to)) continue - const entry = cleanEntry(raw, groupTitles) - if (entry && Object.keys(entry).length > 0) entries.set(to, entry) - } + const { entries } = readOverrides(baseNav, overrides, grouped) if (entries.size === 0) return baseNav if (!grouped) return byOrder(mergeItems(baseNav, entries)) // Grouped: an item may also be moved into another *existing* titled section. - // Groups keep their coded order — only membership and within-group order move. - const moved = new Map() // destination title → items pulled in from elsewhere - const kept = baseNav.map((g) => { - const items = [] - for (const item of g.items) { - const o = entries.get(item.to) - if (o?.group && o.group !== g.title) { - if (!moved.has(o.group)) moved.set(o.group, []) - moved.get(o.group).push(item) - continue - } - items.push(item) - } - return { ...g, items } - }) + const { kept, moved } = regroup(baseNav, entries) return kept .map((g) => ({ @@ -135,4 +158,109 @@ export function applyNavOverrides(baseNav, overrides) { .filter((g) => g.items.length > 0) } +// ── The admin editor's round trip ──────────────────────────────────────── +// +// Two functions, inverse to each other, kept in this file rather than beside the +// editor screen so the thing that *writes* an override and the thing that +// *applies* one can never drift: the rows the admin drags are produced by the +// same merge the site renders, hidden ones included. + +/** + * The base nav plus its stored overrides, as editable rows — always in the + * grouped shape, so one editor handles both navs. + * + * Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so + * they can be un-hidden) and keeps empty groups (so something can be moved back + * into one). Each row carries `defaultLabel`, which is what "reset this label" + * restores and what the input shows as its placeholder. + * + * @param {Array} baseNav the hardcoded nav, flat or grouped + * @param {object|null} overrides the parsed settings JSON + * @returns {Array<{title: string|null, items: Array}>} + */ +export function buildNavRows(baseNav, overrides) { + if (!Array.isArray(baseNav) || baseNav.length === 0) return [] + const grouped = isGrouped(baseNav) + const { entries } = readOverrides(baseNav, overrides, grouped) + + if (!grouped) { + return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }] + } + const { kept, moved } = regroup(baseNav, entries) + return kept.map((g) => ({ + ...g, + title: g.title ?? null, + items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)), + })) +} + +// Did the admin actually move anything? Comparing the edited sequence with the +// coded one is what decides whether orders are written at all: an admin who only +// renamed an item should not pin the position of every other one, or a route +// added in code later would land in an arbitrary place. +// +// The base side is restricted to the rows the editor is actually holding: §8.1 +// filters the palette to what this admin can themselves see, and an item that +// their role or a shard feature kept off the screen is not a reorder. +function orderMatchesBase(groups, baseNav) { + const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`)) + const base = isGrouped(baseNav) + ? baseNav.map((g) => ({ title: g.title ?? null, items: g.items })) + : [{ title: null, items: baseNav }] + const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to))) + const a = flatten(groups) + const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) }))) + return a.length === b.length && a.every((v, i) => v === b[i]) +} + +/** + * The rows the admin has been editing, back as an overrides object to store. + * Only differences from the code default are written — a field that matches the + * default is absent, so the row stays a small statement of intent rather than a + * snapshot of the nav. + * + * @param {Array} groups the editor's groups, in their current order + * @param {Array} baseNav the hardcoded nav these rows came from + * @param {object|null} stored the overrides as loaded, so entries for items + * this admin could not see (role- or feature-gated out of their palette) are + * carried through rather than silently dropped on save + * @returns {object} the overrides to store — `{}` when nothing differs + */ +export function buildNavOverrides(groups, baseNav, stored = null) { + if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {} + const grouped = isGrouped(baseNav) + const baseItems = new Map( + (grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map( + ([item, title]) => [item.to, { label: item.label, group: title }], + ), + ) + + const out = {} + // Carry through what this admin's palette never showed them. An entry for a + // `to` the base nav no longer declares is NOT carried: dropping it is the + // cleanup, and applyNavOverrides ignores it anyway. + const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to))) + if (stored && typeof stored === 'object' && !Array.isArray(stored)) { + for (const [to, entry] of Object.entries(stored)) { + if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry + } + } + + const writeOrder = !orderMatchesBase(groups, baseNav) + for (const group of groups) { + group.items.forEach((row, index) => { + const base = baseItems.get(row.to) + if (!base) return + const entry = {} + const label = typeof row.label === 'string' ? row.label.trim() : '' + if (label && label !== base.label) entry.label = label + if (row.hidden === true) entry.hidden = true + if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title + if (writeOrder) entry.order = index + if (Object.keys(entry).length > 0) out[row.to] = entry + }) + } + return out +} + export default applyNavOverrides diff --git a/client/src/lib/settingsJson.js b/client/src/lib/settingsJson.js new file mode 100644 index 0000000..74c00f7 --- /dev/null +++ b/client/src/lib/settingsJson.js @@ -0,0 +1,32 @@ +// Parse a JSON-valued settings row, client side. +// +// The counterpart to server/src/utils/settingsJson.js, and deliberately the same +// three lines of judgement: `settings.value` is TEXT, so theme_visual, +// brand_assets and the three nav_* keys all arrive as strings, and a malformed +// or wrong-shaped one must read as **absent** — the surface falls back to its +// BRAND_* env / theme.css / hardcoded NAV default — never as an error and never +// as a half-applied object. +// +// THEMING_AND_NAV.md §4.4 planned this "with its first consumer"; that consumer +// is the public header reading nav_public. `parseLayout` in heroLayout.js keeps +// its own version check because it validates a shape, not just a shape's kind. + +/** + * @param {string|null|undefined} str the raw stored value + * @returns {object|null} the parsed object, or null when absent/malformed + */ +export function parseJsonSetting(str) { + if (typeof str !== 'string' || str === '') return null + let parsed + try { + parsed = JSON.parse(str) + } catch { + return null + } + // Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to + // every consumer of these keys as a syntax error is. + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + return parsed +} + +export default parseJsonSetting diff --git a/client/src/lib/useNavOverrides.js b/client/src/lib/useNavOverrides.js new file mode 100644 index 0000000..5d46cfa --- /dev/null +++ b/client/src/lib/useNavOverrides.js @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { api } from '../api/client.js' +import { parseJsonSetting } from './settingsJson.js' + +// The nav overrides for the two authenticated layouts (THEMING_AND_NAV.md §4.2). +// +// `nav_public` rides along in the public settings payload, but `nav_admin` and +// `nav_player` deliberately do not: an anonymous visitor has no use for either, +// and the admin nav's labels describe the shape of the admin surface. Their +// owners read them from GET /api/v1/settings/nav, which any signed-in account +// may call — AdminLayout renders for editors and moderators, who cannot reach +// GET /admin/settings at all. +// +// Failing quiet is the whole posture: a request that errors, a malformed row and +// "not fetched yet" are the same state to the caller, `{}`, which +// applyNavOverrides turns into the coded nav. A sidebar must never blink empty +// because a settings call was slow. + +// One module-level copy, so the second layout to mount renders the nav it +// already knows rather than flashing the coded one, and so the nav editor can +// push its save into the sidebar the admin is looking at without a reload. +let cache = {} +const subscribers = new Set() + +async function load() { + try { + const data = await api.navSettings() + cache = { + nav_admin: parseJsonSetting(data?.nav_admin), + nav_player: parseJsonSetting(data?.nav_player), + } + subscribers.forEach((fn) => fn(cache)) + } catch { + /* the coded nav is the fallback, and it is already on screen */ + } + return cache +} + +/** Re-read the rows after a save, so the live sidebar catches up at once. */ +export function refreshNavOverrides() { + return load() +} + +export function useNavOverrides() { + const [overrides, setOverrides] = useState(cache) + + useEffect(() => { + subscribers.add(setOverrides) + load() + return () => subscribers.delete(setOverrides) + }, []) + + return overrides +} + +export default useNavOverrides diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 37e67f2..f7cbb7f 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -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 = () =>

const IconUser = () => const IconShard = () => +const IconNav = () => const IconPalette = () => // Nav is grouped into collapsible categories. A group with no `title` renders @@ -46,7 +49,11 @@ const IconPalette = () => 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. diff --git a/client/src/routes/admin/views/AppearanceAdmin.jsx b/client/src/routes/admin/views/AppearanceAdmin.jsx index 5e5f417..6251b96 100644 --- a/client/src/routes/admin/views/AppearanceAdmin.jsx +++ b/client/src/routes/admin/views/AppearanceAdmin.jsx @@ -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 || {}, diff --git a/client/src/routes/admin/views/NavEditor.jsx b/client/src/routes/admin/views/NavEditor.jsx new file mode 100644 index 0000000..6404e42 --- /dev/null +++ b/client/src/routes/admin/views/NavEditor.jsx @@ -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 ( + + ) +} + +function EyeIcon({ off }) { + return ( + + ) +} + +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 ( +

  • + + 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. */} + + {row.to} + + {renamed && ( + + )} + {groupTitles.length > 0 && ( + + )} + +
  • + ) +} + +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 + if (error && !state) return + + 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 ( +
    +

    + 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. +

    + + {/* ── Tabs ───────────────────────────────────────────────── */} +
    + {TABS.map((t) => ( + + ))} +
    + + + {activeTab.hint}{' '} + {current.hasRow + ? 'This nav has saved overrides.' + : 'This nav has never been customised, so it renders exactly as coded.'} + + + {/* ── Rows ───────────────────────────────────────────────── */} +
    + {current.groups.map((group, groupIndex) => ( +
    + {group.title && {group.title}} + + i.to)} strategy={verticalListSortingStrategy}> +
      + {group.items.map((row) => ( + + ))} + {group.items.length === 0 && ( +
    • + Empty — this section is not rendered until something is moved into it. +
    • + )} +
    +
    +
    +
    + ))} +
    + +
    + + + {saved === tab && Saved.} + {error && {error}} +
    + +

    + 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. +

    +
    + ) +} diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx index 755c968..7536abd 100644 --- a/client/src/routes/player/PlayerPortalLayout.jsx +++ b/client/src/routes/player/PlayerPortalLayout.jsx @@ -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 = () => const IconShield = () => -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() {