diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 2a2e90b..3ae46d2 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -1,27 +1,87 @@
-import { useEffect } from 'react'
+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'
-// `roles` (when present) restricts which roles see a nav item. Items without it
-// are shown to admin/editor as before. Moderators are further confined to just
-// their own section + account security (see the redirect effect below).
+// 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 IconActivity = () =>
+const IconShield = () =>
+const IconUsers = () =>
+const IconGear = () =>
+const IconHero = () =>
+const IconKey = () =>
+const IconBot = () =>
+const IconPulse = () =>
+const IconUser = () =>
+
+// 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 = [
- { to: '/admin', label: 'Dashboard', end: true },
- { to: '/admin/posts', label: 'Posts' },
- { to: '/admin/wiki', label: 'Wiki' },
- { to: '/admin/hero', label: 'Hero Editor' },
- { to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
- { to: '/admin/settings', label: 'Settings' },
- { to: '/admin/activity', label: 'Activity' },
- { to: '/admin/bot-activity', label: 'Bot Activity' },
- { to: '/admin/discord-bot', label: 'Discord Bot' },
- { to: '/admin/auth-providers', label: 'Authentication' },
- { to: '/admin/users', label: 'Users' },
- { to: '/admin/account', label: 'Account' },
+ {
+ 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/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/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
+ ],
+ },
+ {
+ items: [
+ { to: '/admin/account', label: 'Account', icon: IconUser },
+ ],
+ },
]
+const COLLAPSE_KEY = 'admin.nav.collapsed'
+
const TITLES = {
'/admin': 'Dashboard',
'/admin/posts': 'Posts',
@@ -30,7 +90,7 @@ const TITLES = {
'/admin/moderation': 'Moderation',
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
- '/admin/bot-activity': 'Bot Activity',
+ '/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
@@ -44,7 +104,9 @@ const navBtnBase = {
fontFamily: 'var(--sans)',
fontSize: '0.92rem',
textDecoration: 'none',
- display: 'block',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
transition: 'background .15s,color .15s',
}
@@ -62,11 +124,40 @@ export default function AdminLayout() {
// Moderators only get the moderation section + their own account security.
const isModerator = user?.role === 'moderator'
- const navItems = NAV.filter((n) => {
- if (n.roles && !n.roles.includes(user?.role)) return false
- if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account'
+ 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.
@@ -118,22 +209,71 @@ export default function AdminLayout() {
-