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 ( ) } const IconHome = () => const IconPosts = () => const IconWiki = () => const IconPages = () => const IconActivity = () => const IconShield = () => const IconUsers = () => const IconGear = () => const IconHero = () => const IconKey = () => const IconBot = () => const IconPulse = () => const IconUser = () => const IconShard = () => const IconNav = () => const IconPalette = () => // 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 (
{/* Sidebar */} {/* Main */}

{title}

View site → {(user?.username || 'A').charAt(0)}
) }