Files
website/client/src/routes/admin/AdminLayout.jsx
wtclaude 32a3ff104a feat(theming): wire the three navs and add the admin nav builder
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin
sidebar and the player portal now read their override row, and /admin/navigation
writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a
row into another existing section.

The merge always runs BEFORE the role and shard-feature filters in the layouts,
which are unchanged and remain the boundary. An override is presentation: it
cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored
`hidden: false` on a gated item shows nobody anything.

The design scoped these phases as client work, but the server had no way to
store a nav row: updateSettings validates and stringifies theme_visual and
brand_assets and lets everything else through, so a nav object would have been
written as "[object Object]" and read as absent for ever. utils/navOverrides.js
mirrors utils/brandAssets.js — strict on write with the offending key named,
forgiving on read. It validates shape only; whether a `to` exists is settled
client-side at merge time, because the base NAV arrays are client constants and
a server-side copy would be a second source of truth that drifts.

The nav editor cannot be hidden — its own toggle is disabled, the write path
drops `hidden` on that one `to`, and AdminLayout strips it again before merging,
which also covers a row edited straight in the database.

Orders are written only when the sequence actually differs from the code's, and
the comparison is restricted to the rows the editing admin can see, so renaming
one item does not pin the position of every other one and a role- or
feature-gated item missing from their palette is not mistaken for a reorder.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:02:33 -05:00

407 lines
17 KiB
JavaScript

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).
function Icon({ children, size = 16 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
)
}
const IconHome = () => <Icon><path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></Icon>
const IconPosts = () => <Icon><path d="M5 3h14v18H5z" /><path d="M8 8h8M8 12h8M8 16h5" /></Icon>
const IconWiki = () => <Icon><path d="M4 4h9a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z" /><path d="M20 4h-2a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h2z" /></Icon>
const IconPages = () => <Icon><path d="M5 3h9l5 5v13H5z" /><path d="M14 3v5h5" /><path d="M8 13h8M8 17h8" /></Icon>
const IconActivity = () => <Icon><path d="M3 12h4l3 8 4-16 3 8h4" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconUsers = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20a6 6 0 0 1 12 0" /><path d="M16 6a3 3 0 0 1 0 6M17 20a6 6 0 0 0-3-5" /></Icon>
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconHero = () => <Icon><path d="M3 5h18v14H3z" /><circle cx="8" cy="10" r="1.6" /><path d="M4 18l5-5 3 3 3-4 5 6" /></Icon>
const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18 12v3M15 12v2" /></Icon>
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
// (when present) matches server-side enforcement so the sidebar never shows a
// link that would 403; an item without `roles` is visible to everyone.
// Moderators are further confined to just their section + account (see below).
//
// Exported because Admin -> Navigation edits this list. It stays declared here:
// the editor may relabel, reorder, hide and regroup, and `roles` is never its to
// touch (§7) — navItemVisibleTo below is the filter that still decides.
export const NAV = [
{
items: [
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
],
},
{
title: 'Content',
items: [
{ to: '/admin/posts', label: 'Posts', icon: IconPosts, roles: ['admin', 'editor'] },
{ to: '/admin/pages', label: 'Pages', icon: IconPages, roles: ['admin', 'editor'] },
{ to: '/admin/wiki', label: 'Wiki', icon: IconWiki, roles: ['admin', 'editor'] },
{ to: '/admin/activity', label: 'Activity', icon: IconActivity, roles: ['admin', 'editor'] },
],
},
{
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
]
const COLLAPSE_KEY = 'admin.nav.collapsed'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
// The one row an override may never hide: the nav editor itself, which is the
// only screen that can un-hide anything. The write path already refuses it
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
// this is the third guard, and the one that also covers a row edited straight
// in the database. Cheap, and it makes "cannot be hidden" true without
// qualification.
const UNHIDEABLE = '/admin/navigation'
function keepEditorReachable(overrides) {
const entry = overrides?.[UNHIDEABLE]
if (!entry || entry.hidden !== true) return overrides
const { hidden, ...rest } = entry
return { ...overrides, [UNHIDEABLE]: rest }
}
// Who may see a sidebar row. The single authority for that question: the layout
// applies it after the override merge (overrides are presentation, this is the
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
// admin is never offered a row they cannot themselves see (§8.1).
export function navItemVisibleTo(item, role) {
if (item.roles && !item.roles.includes(role)) return false
if (role === 'moderator') return MOD_PATHS.includes(item.to)
return true
}
const TITLES = {
'/admin': 'Dashboard',
'/admin/posts': 'Posts',
'/admin/pages': 'Pages',
'/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/shard-ops': 'In-Game Ops',
'/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',
'/admin/shard': 'Shard (uo-link)',
'/admin/shard-visibility': 'Shard Visibility',
'/admin/shard-atlas': 'Spawn Atlas',
'/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
}
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User'
return 'Admin'
}
const navBtnBase = {
textAlign: 'left',
borderRadius: 8,
padding: '10px 14px',
fontFamily: 'var(--sans)',
fontSize: '0.92rem',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'background .15s,color .15s',
}
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)
// The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
const isModerator = user?.role === 'moderator'
// 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.
const [collapsed, setCollapsed] = useState(() => {
try {
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {}
} catch {
return {}
}
})
const toggleGroup = (title) => {
setCollapsed((prev) => {
const next = { ...prev, [title]: !prev[title] }
try {
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next))
} catch {
/* private mode / quota — collapse is non-essential */
}
return next
})
}
const activeGroupTitle = navGroups.find((g) =>
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
)?.title
// Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => {
if (!isModerator) return
const p = location.pathname
const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => {
const meta = document.createElement('meta')
meta.name = 'robots'
meta.content = 'noindex, nofollow'
document.head.appendChild(meta)
return () => document.head.removeChild(meta)
}, [])
async function signOut() {
await logout()
navigate('/admin/login', { replace: true })
}
return (
<div className="admin-grid">
{/* Sidebar */}
<aside
style={{
borderRight: '1px solid var(--line)',
background: 'var(--bg)',
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
height: '100vh',
}}
>
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
<BrandLogo height={24} />
<MoonDot />
<div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
{siteTitle}
</div>
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
Admin
</div>
</div>
</div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
{navGroups.map((group) => {
const links = group.items.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className="admin-nav-link"
style={({ isActive }) => ({
...navBtnBase,
background: isActive ? 'var(--blue)' : 'transparent',
color: isActive ? 'var(--ink)' : 'var(--muted)',
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
})}
>
{n.icon && <n.icon />}
<span>{n.label}</span>
</NavLink>
))
// Untitled groups (Dashboard, Account) render their links directly.
if (!group.title) {
return (
<div key={group.items[0]?.to || 'group'} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{links}
</div>
)
}
// Titled groups get a collapsible header. The group with the active
// route stays open regardless of the stored collapse preference.
const isOpen = group.title === activeGroupTitle || !collapsed[group.title]
return (
<div key={group.title} className="admin-nav-group">
<button
type="button"
className="admin-nav-head sans"
onClick={() => toggleGroup(group.title)}
aria-expanded={isOpen}
>
<span>{group.title}</span>
<svg
className="admin-nav-chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isOpen && (
<div className="admin-nav-items" style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{links}
</div>
)}
</div>
)
})}
</nav>
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: '0.78rem', color: 'var(--muted)' }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: modeDot, boxShadow: `0 0 8px ${modeDot}` }} />
Site is&nbsp;<strong style={{ color: 'var(--ink)', textTransform: 'capitalize' }}>{mode}</strong>
</div>
<button
onClick={signOut}
className="sans"
style={{ display: 'block', width: '100%', textAlign: 'center', border: '1px solid var(--line)', borderRadius: 8, padding: 9, color: 'var(--muted)', background: 'transparent', fontSize: '0.84rem', cursor: 'pointer' }}
>
Sign out
</button>
</div>
</aside>
{/* Main */}
<main style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '20px 32px',
borderBottom: '1px solid var(--line-soft)',
background: 'var(--bg)',
position: 'sticky',
top: 0,
zIndex: 10,
}}
>
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
{title}
</h1>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
View site
</a>
<span
style={{ width: 30, height: 30, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '0.8rem', textTransform: 'uppercase' }}
>
{(user?.username || 'A').charAt(0)}
</span>
</div>
</header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: wide ? 'none' : 1000, width: '100%' }}>
<Outlet />
</div>
</main>
</div>
)
}