feat(theming): settings-store, nav merge util and radius tokens
Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI, no consumer wiring, and an instance that never touches the new settings keys renders exactly as it does today. Phase 0 - settings store: - settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to default" primitive. Defaults for these keys live in BRAND_* env, theme.css and the hardcoded NAV arrays, so reset has to delete the row rather than store a copy of the default. Allowlisted to the five theming/nav keys plus hero_layout_draft, admin-only, idempotent. - GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout renders for editors and moderators and PlayerPortalLayout for players, and none of them can read GET /admin/settings, so without this their nav override would silently never apply. - A fifth router group for it: /public is anonymous, /admin/settings is adminOnly, /player is self-scoped data. This is configuration that needs a login. - parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so every JSON key arrives as a string; malformed or wrong-shaped reads as absent, never as an error and never half-applied. - theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and nav_player deliberately do not. Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation only: it can set label/order/hidden and (grouped navs) group, and nothing else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false cannot un-hide anything - the existing filters run afterward, unchanged, and remain the boundary. Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome and the two 50% circles stay literal. --shadow-card and --panel-grad were already tokens. Tests: 16 new server tests, 20 new client tests. The route-manifest guard now also asserts /settings/** sits behind requireAuth. Swagger and both route artifacts regenerated. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
138
client/src/lib/navOverrides.js
Normal file
138
client/src/lib/navOverrides.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// Apply an admin's stored navigation overrides to a hardcoded NAV array.
|
||||
//
|
||||
// The three navs (public header, admin sidebar, player portal) stay declared in
|
||||
// code; this layer only reorders, relabels and hides what is already there.
|
||||
// See docs/website/THEMING_AND_NAV.md §7.
|
||||
//
|
||||
// **This is presentation, never authorization.** The override can carry
|
||||
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
|
||||
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
|
||||
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
|
||||
// *after* this merge, unchanged, and remain the actual boundary. An override
|
||||
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
|
||||
// whose role check fails: hiding is subtractive here, never additive.
|
||||
//
|
||||
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
|
||||
// label, a group that does not exist — is ignored rather than rejected, so a
|
||||
// stale or hand-edited settings row degrades to the code default instead of
|
||||
// rendering a broken nav.
|
||||
|
||||
// Two shapes are supported, because two exist:
|
||||
// flat [{ to, label, ... }] — public header, player portal
|
||||
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
|
||||
function isGrouped(nav) {
|
||||
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
||||
}
|
||||
|
||||
// A stored override entry is usable only field by field: a bad `label` must not
|
||||
// discard a good `order` alongside it.
|
||||
function cleanEntry(raw, groupTitles) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
|
||||
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
|
||||
if (raw.hidden === true) out.hidden = true
|
||||
// `group` may only name a section the base nav already declares. Anything else
|
||||
// — a renamed group, a typo, an invented category — is dropped, so an item can
|
||||
// never land in a header that does not exist.
|
||||
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort by effective order, where an item the admin never reordered keeps its
|
||||
// index in the base array as its key. Two tie-breaks, in order: an explicit
|
||||
// order beats a coincidental index (the admin said "first", so first), and two
|
||||
// explicit orders stay in code order (the sort is stable).
|
||||
//
|
||||
// In practice the editor writes an order for every item in a list, the way
|
||||
// drag-and-drop reordering does, so ties are the stale-row case rather than the
|
||||
// normal one. They still have to resolve predictably.
|
||||
function byOrder(items) {
|
||||
return items
|
||||
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ item }) => {
|
||||
const { __order, ...rest } = item
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden) 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 })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
|
||||
* `roles`, `feature`, `icon` and `end`
|
||||
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
|
||||
* null when the admin never touched this nav
|
||||
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
|
||||
* is nothing to apply
|
||||
*/
|
||||
export function applyNavOverrides(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
// The untouched path, and the one that matters most: no row, a malformed row,
|
||||
// or a row with nothing usable in it all render the nav exactly as coded.
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
})
|
||||
|
||||
return kept
|
||||
.map((g) => ({
|
||||
...g,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
|
||||
}))
|
||||
// A group whose every item was hidden must not leave an orphaned header.
|
||||
// AdminLayout drops empty groups again after its own role filter; doing it
|
||||
// here too keeps the util correct on its own.
|
||||
.filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
export default applyNavOverrides
|
||||
Reference in New Issue
Block a user