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() {