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>
115 lines
4.8 KiB
JavaScript
115 lines
4.8 KiB
JavaScript
import { useMemo } from 'react'
|
|
import { Link, NavLink } from 'react-router-dom'
|
|
import MoonDot from './MoonDot.jsx'
|
|
import BrandLogo from './BrandLogo.jsx'
|
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
|
import { useSite } from '../contexts/SiteContext.jsx'
|
|
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
|
import { applyNavOverrides } from '../lib/navOverrides.js'
|
|
import { parseJsonSetting } from '../lib/settingsJson.js'
|
|
|
|
// One consistent top nav for the whole public site. Every page gets the same
|
|
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
|
//
|
|
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
|
|
// to a higher audience (Admin -> 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.
|
|
//
|
|
// 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' },
|
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
|
{ label: 'Wiki', to: '/wiki' },
|
|
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
|
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
|
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
|
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
|
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
|
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
|
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
|
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
|
{ label: 'Market', to: '/site/market', feature: 'market' },
|
|
{ label: 'About', to: '/site/about' },
|
|
]
|
|
|
|
const linkStyle = ({ isActive }) => ({
|
|
background: isActive ? 'var(--accent)' : undefined,
|
|
color: isActive ? 'var(--bg-deep)' : undefined,
|
|
borderColor: isActive ? 'var(--accent)' : undefined,
|
|
})
|
|
|
|
export default function SiteHeader() {
|
|
const { user, loading } = useAuth()
|
|
const { siteTitle, settings } = useSite()
|
|
const shardFeatures = useShardFeatures()
|
|
|
|
// 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
|
|
if (user && user.role && user.role !== 'player') account = { label: 'Admin', to: '/admin' }
|
|
else if (user) account = { label: 'My Account', to: '/player' }
|
|
else account = { label: 'Sign in', to: '/account/login' }
|
|
|
|
return (
|
|
<header
|
|
style={{
|
|
borderBottom: '1px solid var(--line)',
|
|
background: 'rgba(9,13,18,0.86)',
|
|
backdropFilter: 'blur(8px)',
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 30,
|
|
}}
|
|
>
|
|
<div
|
|
className="shell"
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, padding: '14px 0', flexWrap: 'wrap' }}
|
|
>
|
|
<Link
|
|
to="/"
|
|
className="display"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
|
>
|
|
<BrandLogo height={22} />
|
|
<MoonDot />
|
|
{siteTitle}
|
|
</Link>
|
|
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
|
{nav.map((l) => (
|
|
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
|
{l.label}
|
|
</NavLink>
|
|
))}
|
|
{!loading && (
|
|
<NavLink
|
|
to={account.to}
|
|
className="pill"
|
|
style={{ marginLeft: 6, borderColor: 'var(--accent)', color: 'var(--accent-bright)' }}
|
|
>
|
|
{account.label}
|
|
</NavLink>
|
|
)}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|