Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data pipeline; this makes it reachable — six public routes, five admin ones, two public pages and an admin panel. Still website-only: no plugin, no sidecar, no new event kinds, no wire change. The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here touches the sidecar, so the pages stay complete while the shard is down, and a /shard prefix would imply a dependency the atlas does not have. Unlike /shard/* it IS site-mode gated, like /posts and /wiki: a bestiary is site content. Every route carries requireFeature('atlas') and projects its response. The atlas feature declares no sensitive fields, so the projection is a no-op today — the call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate should be covered by construction rather than by a retrofit. Two bugs the UI surfaced, both fixed here: Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging it per record with DelayInSec. A `5` means five minutes on one spawner and five seconds on the next, both plausible, and the pipeline stored the raw number. 170 of 6,455 stock spawners are second flagged. The parser normalises to seconds; the API and UI carry seconds. That exposed the hash gate as a trap. "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. PARSER_VERSION is now stored beside the source hashes and a mismatch counts as drift, so any future parse correction lands on the next boot. Also renamed the detail route's spawn-point array to `spawners` — it was `points`, which is the COUNT on the search route, so one key meant a number in one place and an array in the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
362 lines
15 KiB
JavaScript
362 lines
15 KiB
JavaScript
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 (
|
|
<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>
|
|
|
|
// 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'] },
|
|
{ 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/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'
|
|
|
|
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/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 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)'
|
|
|
|
// Moderators only get the moderation section (Discord + in-game ops) + their
|
|
// own account security.
|
|
const isModerator = user?.role === 'moderator'
|
|
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
|
const visible = (item) => {
|
|
if (item.roles && !item.roles.includes(user?.role)) return false
|
|
if (isModerator) return MOD_PATHS.includes(item.to)
|
|
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
|
|
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 }}>
|
|
<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 <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>
|
|
)
|
|
}
|