feat(panel): group sidebar nav so admins see both admin and player views
Some checks failed
Deploy to Production / test (push) Failing after 34s
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:
@@ -27,25 +27,165 @@ interface NavItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{ path: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ 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 },
|
||||
{ path: "/games", label: "Games", icon: Gamepad2 },
|
||||
interface NavGroup {
|
||||
label: string | null;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const adminNavGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Administration",
|
||||
items: [
|
||||
{ path: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ 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[] = [
|
||||
{ path: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/games", label: "Games", icon: Gamepad2 },
|
||||
{ path: "/leaderboards", label: "Leaderboards", icon: Trophy },
|
||||
const playerNavGroups: NavGroup[] = [
|
||||
{
|
||||
label: null,
|
||||
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({
|
||||
user,
|
||||
logout,
|
||||
@@ -60,11 +200,8 @@ export default function Layout({
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItems = user.role === "admin" ? adminNavItems : playerNavItems;
|
||||
|
||||
const avatarUrl = user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
|
||||
: null;
|
||||
const navGroups = user.role === "admin" ? adminNavGroups : playerNavGroups;
|
||||
const showLabels = !collapsed || mobileOpen;
|
||||
|
||||
// Close mobile drawer on route change
|
||||
useEffect(() => {
|
||||
@@ -83,63 +220,6 @@ export default function Layout({
|
||||
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 (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Mobile header bar */}
|
||||
@@ -165,10 +245,8 @@ export default function Layout({
|
||||
<aside
|
||||
className={cn(
|
||||
"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",
|
||||
mobileOpen && "translate-x-0",
|
||||
// Desktop: respect collapsed state
|
||||
!mobileOpen && collapsed && "md:w-16"
|
||||
)}
|
||||
>
|
||||
@@ -176,7 +254,6 @@ export default function Layout({
|
||||
<div className="font-display text-xl font-bold tracking-tight">
|
||||
{collapsed && !mobileOpen ? "A" : "Aurora"}
|
||||
</div>
|
||||
{/* Close button on mobile */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground md:hidden"
|
||||
@@ -185,12 +262,31 @@ export default function Layout({
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<main className={cn(
|
||||
"flex-1 transition-all duration-200",
|
||||
// Mobile: no margin, pad for top header bar
|
||||
"mt-14 md:mt-0",
|
||||
collapsed ? "md:ml-16" : "md:ml-60"
|
||||
)}>
|
||||
|
||||
Reference in New Issue
Block a user