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'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.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 IconNav = () =>
const IconPalette = () =>
const IconModules = () =>
// 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'] },
],
},
{
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'] },
// Admin-only, matching the server: every route under /admin/modules
// re-gates to `admin` on top of the group's staff gate, because installing
// a module runs its code in this process.
{ to: '/admin/modules', label: 'Modules', icon: IconModules, 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/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
]
const COLLAPSE_KEY = 'admin.nav.collapsed'
// 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, and where that lets them go, both derived from the
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
// because Admin -> Navigation has always imported it from here.
export { navItemVisibleTo }
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/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/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
// not know what they are called. Their nav row does, so the row is the title:
// the longest matching module row wins, so a detail page under a section titles
// as that section rather than falling through to a bare "Admin". Restricted to
// rows a module registered, which is what keeps every core path resolving
// through TITLES and sectionTitle exactly as it does today.
function moduleTitle(baseNav, pathname) {
return baseNav
.flatMap((g) => g.items)
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
// 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/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 isVisible = useFeatureGate()
// Core's rows plus every installed module's, before the override merge sees
// them — so a module row is editable in Admin -> Navigation like any other
// (modules/nav.js). Computed once: the registry is fixed before the first
// render and nothing unregisters.
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
const title =
TITLES[location.pathname] || moduleTitle(baseNav, 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(baseNav, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({
...g,
// `isVisible` is a no-op for every core row — none carries a `feature`
// — and is applied here so that a module row which does carry one is
// gated on the sidebar rather than silently advertised.
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
}))
// Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0),
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
)
// 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
// Where a moderator may go, from the same `roles` that decide what they see.
// It used to be a third hardcoded list — a prefix check over three paths —
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
// was on the sidebar and not in the redirect, so a moderator who clicked
// Houses in their own nav was bounced straight back to Moderation. One
// derivation cannot disagree with itself, which is the point of deriving it.
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
// 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
if (!isAllowedPath(location.pathname, allowed)) {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate, allowed])
// 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 (