import { useEffect, useState } from 'react' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import MoonDot from '../../components/MoonDot.jsx' import { useAuth } from '../../contexts/AuthContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx' // 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 = () => // 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). 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'] }, ], }, { title: 'System', items: [ { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, 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/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' const TITLES = { '/admin': 'Dashboard', '/admin/posts': 'Posts', '/admin/pages': 'Pages', '/admin/wiki': 'Wiki Pages', '/admin/hero': 'Hero Editor', '/admin/moderation': 'Moderation', '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', '/admin/discord-bot': 'Discord Bot', '/admin/shard': 'Shard (uo-link)', '/admin/characters': 'My Characters', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', '/admin/account': 'Account Security', } 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 } = useSite() const navigate = useNavigate() const location = useLocation() const title = TITLES[location.pathname] || (location.pathname.startsWith('/admin/moderation') ? 'Moderation' : location.pathname.startsWith('/admin/characters') ? 'My Characters' : 'Admin') // 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)' // Moderators only get the moderation section + their own account security. const isModerator = user?.role === 'moderator' const visible = (item) => { if (item.roles && !item.roles.includes(user?.role)) return false if (isModerator) return item.to === '/admin/moderation' || item.to === '/admin/account' 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) // 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 if (!p.startsWith('/admin/moderation') && p !== '/admin/account') { 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)}
) }