From 17d42cebfe7ce1e4028de59b7d2b02c90d8a47b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:54:37 -0500 Subject: [PATCH] Redesign admin sidebar: collapsible categories, icons, role-accurate nav Regroup the flat 12-link staff sidebar into collapsible category sections (Content / Moderation / System, with Dashboard and Account ungrouped) and add a small inline-SVG icon per item. Category collapse state persists in localStorage and the group holding the active route auto-opens. Gate each item by role to match server-side enforcement so the sidebar no longer shows links that would 403: Content is admin/editor, Moderation is admin/moderator, System (Users, Settings, Hero Editor, Authentication, Discord Bot, Web Bot Activity) is admin-only. Existing moderator confinement (Moderation + Account only, plus redirect) is preserved. Rename "Bot Activity" to "Web Bot Activity" to distinguish the bot-scoring view from the Discord Bot. Co-Authored-By: Claude Opus 4.8 --- client/src/routes/admin/AdminLayout.jsx | 214 ++++++++++++++++++++---- client/src/styles/theme.css | 41 +++++ 2 files changed, 218 insertions(+), 37 deletions(-) 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() { -