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'
: location.pathname.startsWith('/admin/users/')
? 'User'
: '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 */}
)
}