feat(panel): group sidebar nav so admins see both admin and player views
Some checks failed
Deploy to Production / test (push) Failing after 34s

Introduces NavGroup structure with labeled sections in the sidebar.
Admins see "Administration" and "Player" groups; players see a flat
list unchanged. Extracts SidebarNavItem, SidebarNavSection, and
SidebarUserProfile components from the monolithic sidebarContent blob.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-05 15:45:04 +02:00
parent 94d259e92a
commit 838fbe1b50

View File

@@ -27,25 +27,165 @@ interface NavItem {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
} }
const adminNavItems: NavItem[] = [ interface NavGroup {
{ path: "/admin", label: "Dashboard", icon: LayoutDashboard }, label: string | null;
{ path: "/admin/users", label: "Users", icon: Users }, items: NavItem[];
{ path: "/admin/items", label: "Items", icon: Package }, }
{ path: "/admin/classes", label: "Classes", icon: GraduationCap },
{ path: "/admin/quests", label: "Quests", icon: Scroll }, const adminNavGroups: NavGroup[] = [
{ path: "/admin/lootdrops", label: "Lootdrops", icon: Gift }, {
{ path: "/admin/moderation", label: "Moderation", icon: Shield }, label: "Administration",
{ path: "/admin/transactions", label: "Transactions", icon: ArrowLeftRight }, items: [
{ path: "/admin/settings", label: "Settings", icon: Settings }, { path: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ path: "/games", label: "Games", icon: Gamepad2 }, { path: "/admin/users", label: "Users", icon: Users },
{ path: "/admin/items", label: "Items", icon: Package },
{ path: "/admin/classes", label: "Classes", icon: GraduationCap },
{ path: "/admin/quests", label: "Quests", icon: Scroll },
{ path: "/admin/lootdrops", label: "Lootdrops", icon: Gift },
{ path: "/admin/moderation", label: "Moderation", icon: Shield },
{ path: "/admin/transactions", label: "Transactions", icon: ArrowLeftRight },
{ path: "/admin/settings", label: "Settings", icon: Settings },
],
},
{
label: "Player",
items: [
{ path: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ path: "/games", label: "Games", icon: Gamepad2 },
{ path: "/leaderboards", label: "Leaderboards", icon: Trophy },
],
},
]; ];
const playerNavItems: NavItem[] = [ const playerNavGroups: NavGroup[] = [
{ path: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, {
{ path: "/games", label: "Games", icon: Gamepad2 }, label: null,
{ path: "/leaderboards", label: "Leaderboards", icon: Trophy }, items: [
{ path: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ path: "/games", label: "Games", icon: Gamepad2 },
{ path: "/leaderboards", label: "Leaderboards", icon: Trophy },
],
},
]; ];
function SidebarNavItem({
path,
label,
icon: Icon,
active,
showLabel,
onNavigate,
}: NavItem & { active: boolean; showLabel: boolean; onNavigate: (path: string) => void }) {
return (
<button
onClick={() => onNavigate(path)}
className={cn(
"w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
active
? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5 shrink-0", active && "text-primary")} />
{showLabel && <span>{label}</span>}
</button>
);
}
function SidebarNavSection({
group,
index,
showLabels,
isActive,
onNavigate,
}: {
group: NavGroup;
index: number;
showLabels: boolean;
isActive: (path: string) => boolean;
onNavigate: (path: string) => void;
}) {
return (
<div className={cn(index > 0 && "mt-4")}>
{group.label && showLabels && (
<div className="px-3 pb-1.5 pt-1 text-xs font-semibold uppercase tracking-wider text-text-tertiary/60">
{group.label}
</div>
)}
{!showLabels && index > 0 && (
<div className="mx-3 mb-2 border-t border-white/10" />
)}
<div className="space-y-1">
{group.items.map((item) => (
<SidebarNavItem
key={item.path}
{...item}
active={isActive(item.path)}
showLabel={showLabels}
onNavigate={onNavigate}
/>
))}
</div>
</div>
);
}
function SidebarUserProfile({
user,
showLabels,
collapsed,
mobileOpen,
logout,
onToggleCollapse,
}: {
user: AuthUser;
showLabels: boolean;
collapsed: boolean;
mobileOpen: boolean;
logout: () => Promise<void>;
onToggleCollapse: () => void;
}) {
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
: null;
return (
<div className="pt-3 p-3 space-y-2">
{showLabels && (
<div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{user.username[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.username}</div>
</div>
</div>
)}
<div className={cn("flex", collapsed && !mobileOpen ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<button
onClick={logout}
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
{showLabels && <span>Sign out</span>}
</button>
<button
onClick={onToggleCollapse}
className="hidden md:block p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
);
}
export default function Layout({ export default function Layout({
user, user,
logout, logout,
@@ -60,11 +200,8 @@ export default function Layout({
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const navItems = user.role === "admin" ? adminNavItems : playerNavItems; const navGroups = user.role === "admin" ? adminNavGroups : playerNavGroups;
const showLabels = !collapsed || mobileOpen;
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
: null;
// Close mobile drawer on route change // Close mobile drawer on route change
useEffect(() => { useEffect(() => {
@@ -83,63 +220,6 @@ export default function Layout({
setMobileOpen(false); setMobileOpen(false);
} }
const sidebarContent = (
<>
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
{navItems.map(({ path, label, icon: Icon }) => (
<button
key={path}
onClick={() => handleNav(path)}
className={cn(
"w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
isActive(path)
? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} />
{(!collapsed || mobileOpen) && <span>{label}</span>}
</button>
))}
</nav>
<div className="pt-3 p-3 space-y-2">
{(!collapsed || mobileOpen) && (
<div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{user.username[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.username}</div>
</div>
</div>
)}
<div className={cn("flex", collapsed && !mobileOpen ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<button
onClick={logout}
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
{(!collapsed || mobileOpen) && <span>Sign out</span>}
</button>
{/* Collapse toggle only on desktop */}
<button
onClick={() => setCollapsed((c) => !c)}
className="hidden md:block p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
</>
);
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex">
{/* Mobile header bar */} {/* Mobile header bar */}
@@ -165,10 +245,8 @@ export default function Layout({
<aside <aside
className={cn( className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col bg-surface-container-low transition-all duration-200", "fixed inset-y-0 left-0 z-50 flex flex-col bg-surface-container-low transition-all duration-200",
// Mobile: off-screen drawer, shown when mobileOpen
"w-60 -translate-x-full md:translate-x-0", "w-60 -translate-x-full md:translate-x-0",
mobileOpen && "translate-x-0", mobileOpen && "translate-x-0",
// Desktop: respect collapsed state
!mobileOpen && collapsed && "md:w-16" !mobileOpen && collapsed && "md:w-16"
)} )}
> >
@@ -176,7 +254,6 @@ export default function Layout({
<div className="font-display text-xl font-bold tracking-tight"> <div className="font-display text-xl font-bold tracking-tight">
{collapsed && !mobileOpen ? "A" : "Aurora"} {collapsed && !mobileOpen ? "A" : "Aurora"}
</div> </div>
{/* Close button on mobile */}
<button <button
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground md:hidden" className="p-1.5 rounded-md text-text-tertiary hover:text-foreground md:hidden"
@@ -185,12 +262,31 @@ export default function Layout({
</button> </button>
</div> </div>
{sidebarContent} <nav className="flex-1 py-3 px-2 overflow-y-auto">
{navGroups.map((group, i) => (
<SidebarNavSection
key={i}
group={group}
index={i}
showLabels={showLabels}
isActive={isActive}
onNavigate={handleNav}
/>
))}
</nav>
<SidebarUserProfile
user={user}
showLabels={showLabels}
collapsed={collapsed}
mobileOpen={mobileOpen}
logout={logout}
onToggleCollapse={() => setCollapsed((c) => !c)}
/>
</aside> </aside>
<main className={cn( <main className={cn(
"flex-1 transition-all duration-200", "flex-1 transition-all duration-200",
// Mobile: no margin, pad for top header bar
"mt-14 md:mt-0", "mt-14 md:mt-0",
collapsed ? "md:ml-16" : "md:ml-60" collapsed ? "md:ml-16" : "md:ml-60"
)}> )}>