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,7 +27,15 @@ interface NavItem {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
} }
const adminNavItems: NavItem[] = [ interface NavGroup {
label: string | null;
items: NavItem[];
}
const adminNavGroups: NavGroup[] = [
{
label: "Administration",
items: [
{ path: "/admin", label: "Dashboard", icon: LayoutDashboard }, { path: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ path: "/admin/users", label: "Users", icon: Users }, { path: "/admin/users", label: "Users", icon: Users },
{ path: "/admin/items", label: "Items", icon: Package }, { path: "/admin/items", label: "Items", icon: Package },
@@ -37,74 +45,113 @@ const adminNavItems: NavItem[] = [
{ path: "/admin/moderation", label: "Moderation", icon: Shield }, { path: "/admin/moderation", label: "Moderation", icon: Shield },
{ path: "/admin/transactions", label: "Transactions", icon: ArrowLeftRight }, { path: "/admin/transactions", label: "Transactions", icon: ArrowLeftRight },
{ path: "/admin/settings", label: "Settings", icon: Settings }, { path: "/admin/settings", label: "Settings", icon: Settings },
{ path: "/games", label: "Games", icon: Gamepad2 }, ],
]; },
{
const playerNavItems: NavItem[] = [ label: "Player",
items: [
{ path: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { path: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ path: "/games", label: "Games", icon: Gamepad2 }, { path: "/games", label: "Games", icon: Gamepad2 },
{ path: "/leaderboards", label: "Leaderboards", icon: Trophy }, { path: "/leaderboards", label: "Leaderboards", icon: Trophy },
],
},
]; ];
export default function Layout({ const playerNavGroups: NavGroup[] = [
user, {
logout, label: null,
children, items: [
}: { { path: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
user: AuthUser; { path: "/games", label: "Games", icon: Gamepad2 },
logout: () => Promise<void>; { path: "/leaderboards", label: "Leaderboards", icon: Trophy },
children: React.ReactNode; ],
}) { },
const [collapsed, setCollapsed] = useState(false); ];
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const navItems = user.role === "admin" ? adminNavItems : playerNavItems; function SidebarNavItem({
path,
const avatarUrl = user.avatar label,
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64` icon: Icon,
: null; active,
showLabel,
// Close mobile drawer on route change onNavigate,
useEffect(() => { }: NavItem & { active: boolean; showLabel: boolean; onNavigate: (path: string) => void }) {
setMobileOpen(false); return (
}, [location.pathname]);
function isActive(path: string): boolean {
if (path === "/admin" && location.pathname === "/admin") return true;
if (path === "/dashboard" && location.pathname === "/dashboard") return true;
if (path !== "/admin" && path !== "/dashboard" && location.pathname.startsWith(path)) return true;
return false;
}
function handleNav(path: string) {
navigate(path);
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 <button
key={path} onClick={() => onNavigate(path)}
onClick={() => handleNav(path)}
className={cn( className={cn(
"w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors", "w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
isActive(path) active
? "bg-primary/15 text-primary border-l-4 border-primary" ? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground" : "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)} )}
> >
<Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} /> <Icon className={cn("w-5 h-5 shrink-0", active && "text-primary")} />
{(!collapsed || mobileOpen) && <span>{label}</span>} {showLabel && <span>{label}</span>}
</button> </button>
))} );
</nav> }
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"> <div className="pt-3 p-3 space-y-2">
{(!collapsed || mobileOpen) && ( {showLabels && (
<div className="flex items-center gap-3 px-2 py-1.5"> <div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? ( {avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" /> <img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
@@ -125,11 +172,10 @@ export default function Layout({
title="Sign out" title="Sign out"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
{(!collapsed || mobileOpen) && <span>Sign out</span>} {showLabels && <span>Sign out</span>}
</button> </button>
{/* Collapse toggle only on desktop */}
<button <button
onClick={() => setCollapsed((c) => !c)} onClick={onToggleCollapse}
className="hidden md:block p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors" 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"} title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
> >
@@ -137,8 +183,42 @@ export default function Layout({
</button> </button>
</div> </div>
</div> </div>
</>
); );
}
export default function Layout({
user,
logout,
children,
}: {
user: AuthUser;
logout: () => Promise<void>;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const navGroups = user.role === "admin" ? adminNavGroups : playerNavGroups;
const showLabels = !collapsed || mobileOpen;
// Close mobile drawer on route change
useEffect(() => {
setMobileOpen(false);
}, [location.pathname]);
function isActive(path: string): boolean {
if (path === "/admin" && location.pathname === "/admin") return true;
if (path === "/dashboard" && location.pathname === "/dashboard") return true;
if (path !== "/admin" && path !== "/dashboard" && location.pathname.startsWith(path)) return true;
return false;
}
function handleNav(path: string) {
navigate(path);
setMobileOpen(false);
}
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex">
@@ -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"
)}> )}>