Skip to content

Commit b9f1a94

Browse files
Edwin Chanclaude
authored andcommitted
feat(nav): mount the sidebar from the root layout for every page
Move the sidebar out of /dashboard and into a server component the root layout renders whenever there's a session. Nav links derive the active state from usePathname so the same component works on problem sets, practice, FTW, settings — anywhere a logged-in user lands. The page body gets pushed in via a body class so the public landing page stays unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 301a497 commit b9f1a94

6 files changed

Lines changed: 225 additions & 113 deletions

File tree

app/dashboard/page.tsx

Lines changed: 0 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,22 @@ import { getServerSession } from "next-auth/next";
44
import { redirect } from "next/navigation";
55
import {
66
ArrowRight,
7-
BarChart3,
87
CheckCircle2,
98
ClipboardList,
109
FileJson,
11-
Gauge,
12-
ListChecks,
1310
MessageSquareWarning,
14-
PenLine,
1511
PlayCircle,
16-
Settings,
1712
Sparkles,
18-
Swords,
19-
Target,
20-
Trophy,
21-
User,
22-
Users,
2313
} from "lucide-react";
2414
import { prisma } from "@/lib/db";
2515
import { authOptions } from "@/lib/auth";
2616
import { AuthButton } from "@/app/auth-button";
2717
import { ThemeToggle } from "@/app/theme-toggle";
2818
import { TypewriterGreeting } from "@/app/typewriter-greeting";
29-
import { MobileNavScrim, MobileNavToggle } from "./mobile-nav-toggle";
3019
import { isVisibleToStudent } from "@/lib/visibility";
3120
import { profilePathFromEmail } from "@/lib/user-profile";
3221
import { computeBestAverageScore } from "@/lib/analytics";
3322
import { normalizeTagList } from "@/lib/problem-tags";
34-
import { hasPermission } from "@/lib/permissions";
3523
import { compareProblemSetRecords } from "@/lib/problem-set-order";
3624

3725
export const dynamic = "force-dynamic";
@@ -108,11 +96,6 @@ export default async function DashboardPage() {
10896
const visibleSets = (
10997
currentUser.role === "ADMIN" ? allSets : allSets.filter((set) => isVisibleToStudent(set))
11098
).sort(compareProblemSetRecords);
111-
const canManageContent = hasPermission(currentUser.role, "admin:content");
112-
const canViewStudents = hasPermission(currentUser.role, "admin:users");
113-
const canViewAnalytics = hasPermission(currentUser.role, "admin:analytics");
114-
const canManageFeedback = hasPermission(currentUser.role, "admin:feedback");
115-
const canViewAudit = hasPermission(currentUser.role, "admin:audit");
11699

117100
const attemptsBySet = new Map<
118101
string,
@@ -266,90 +249,6 @@ export default async function DashboardPage() {
266249

267250
return (
268251
<main className="app-shell">
269-
<MobileNavScrim />
270-
<aside className="sidebar" aria-label="Primary" tabIndex={0}>
271-
<nav className="nav-list">
272-
<Link className="nav-item active" href="/dashboard">
273-
<Gauge size={18} />
274-
<span className="nav-label">Dashboard</span>
275-
</Link>
276-
<Link className="nav-item" href="/problem-sets">
277-
<ClipboardList size={18} />
278-
<span className="nav-label">Problem Sets</span>
279-
</Link>
280-
<Link className="nav-item" href="/practice">
281-
<Target size={18} />
282-
<span className="nav-label">Practice</span>
283-
</Link>
284-
<Link className="nav-item" href="/ftw">
285-
<Swords size={18} />
286-
<span className="nav-label">FTW</span>
287-
</Link>
288-
{hasPermission(currentUser.role, "admin:view") ? (
289-
<>
290-
{canManageContent ? (
291-
<>
292-
<Link className="nav-item" href="/admin/sets">
293-
<ListChecks size={18} />
294-
<span className="nav-label">Manage Sets</span>
295-
</Link>
296-
<Link className="nav-item" href="/admin/create">
297-
<PenLine size={18} />
298-
<span className="nav-label">Create Set</span>
299-
</Link>
300-
<Link className="nav-item" href="/admin/import">
301-
<FileJson size={18} />
302-
<span className="nav-label">JSON Import</span>
303-
</Link>
304-
</>
305-
) : null}
306-
{canViewStudents ? (
307-
<Link className="nav-item" href="/admin/students">
308-
<Users size={18} />
309-
<span className="nav-label">Students</span>
310-
</Link>
311-
) : null}
312-
{canViewAnalytics ? (
313-
<Link className="nav-item" href="/admin/analytics">
314-
<BarChart3 size={18} />
315-
<span className="nav-label">Analytics</span>
316-
</Link>
317-
) : null}
318-
{canManageFeedback ? (
319-
<Link className="nav-item" href="/admin/feedback">
320-
<MessageSquareWarning size={18} />
321-
<span className="nav-label">Feedback</span>
322-
</Link>
323-
) : null}
324-
{canViewAudit ? (
325-
<Link className="nav-item" href="/admin/audit">
326-
<CheckCircle2 size={18} />
327-
<span className="nav-label">Audit</span>
328-
</Link>
329-
) : null}
330-
</>
331-
) : null}
332-
<Link className="nav-item" href="/users">
333-
<Users size={18} />
334-
<span className="nav-label">Users</span>
335-
</Link>
336-
<Link className="nav-item" href={profileHref}>
337-
<User size={18} />
338-
<span className="nav-label">My Profile</span>
339-
</Link>
340-
<Link className="nav-item" href="/leaderboard">
341-
<Trophy size={18} />
342-
<span className="nav-label">Leaderboard</span>
343-
</Link>
344-
<Link className="nav-item" href="/settings">
345-
<Settings size={18} />
346-
<span className="nav-label">Settings</span>
347-
</Link>
348-
</nav>
349-
350-
<div className="sidebar-footer" />
351-
</aside>
352-
353252
<section className="workspace">
354253
<header className="topbar">
355254
<div>
@@ -359,7 +258,6 @@ export default async function DashboardPage() {
359258
<h1>training dashboard</h1>
360259
</div>
361260
<div className="topbar-actions">
362-
<MobileNavToggle />
363261
<ThemeToggle />
364262
<AuthButton
365263
avatarUrl={currentUser.avatarUrl}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Menu, X } from "lucide-react";
55

66
const OPEN_CLASS = "mobile-nav-open";
77

8-
export function MobileNavToggle() {
8+
export function GlobalMobileNavToggle() {
99
const [open, setOpen] = useState(false);
1010

1111
useEffect(() => {
@@ -34,7 +34,7 @@ export function MobileNavToggle() {
3434
return (
3535
<button
3636
type="button"
37-
className="mobile-nav-toggle"
37+
className="mobile-nav-toggle global-mobile-nav-toggle"
3838
aria-label={open ? "Close navigation" : "Open navigation"}
3939
aria-expanded={open}
4040
onClick={() => setOpen((prev) => !prev)}
@@ -44,7 +44,7 @@ export function MobileNavToggle() {
4444
);
4545
}
4646

47-
export function MobileNavScrim() {
47+
export function GlobalMobileNavScrim() {
4848
return (
4949
<button
5050
type="button"

app/globals.css

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,19 @@ table {
142142

143143
.app-shell {
144144
position: relative;
145-
display: grid;
146-
grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr);
145+
display: block;
146+
min-height: 100vh;
147+
}
148+
149+
.site-content {
147150
min-height: 100vh;
148151
}
149152

153+
body.has-site-sidebar .site-content {
154+
padding-left: var(--sidebar-collapsed-width);
155+
transition: padding-left 260ms cubic-bezier(0.22, 1, 0.36, 1);
156+
}
157+
150158
.single-page {
151159
position: relative;
152160
min-height: 100vh;
@@ -2039,6 +2047,16 @@ html:not(.light) .problem-jump-button {
20392047
color: var(--color-pink);
20402048
}
20412049

2050+
.global-mobile-nav-toggle {
2051+
position: fixed;
2052+
top: 14px;
2053+
left: 14px;
2054+
z-index: 41;
2055+
width: 42px;
2056+
height: 42px;
2057+
box-shadow: 0 6px 18px rgba(8, 10, 18, 0.18);
2058+
}
2059+
20422060
.mobile-nav-scrim {
20432061
display: none;
20442062
position: fixed;
@@ -2055,7 +2073,12 @@ html:not(.light) .problem-jump-button {
20552073
grid-template-columns: 1fr;
20562074
}
20572075

2058-
.mobile-nav-toggle {
2076+
body.has-site-sidebar .site-content {
2077+
padding-left: 0;
2078+
}
2079+
2080+
.mobile-nav-toggle,
2081+
.global-mobile-nav-toggle {
20592082
display: inline-flex;
20602083
}
20612084

@@ -2433,8 +2456,7 @@ html {
24332456
}
24342457

24352458
.workspace {
2436-
margin-left: var(--sidebar-collapsed-width);
2437-
width: calc(100% - var(--sidebar-collapsed-width));
2459+
width: 100%;
24382460
min-height: 100vh;
24392461
max-width: none;
24402462
padding: 32px;
@@ -2474,6 +2496,39 @@ html {
24742496
overflow-y: auto;
24752497
}
24762498

2499+
.sidebar-brand {
2500+
display: grid;
2501+
place-items: center;
2502+
margin: 0 auto 8px;
2503+
padding: 4px;
2504+
border-radius: 14px;
2505+
text-decoration: none;
2506+
}
2507+
2508+
.sidebar-brand-mark {
2509+
display: grid;
2510+
width: 44px;
2511+
height: 44px;
2512+
place-items: center;
2513+
border-radius: 12px;
2514+
overflow: hidden;
2515+
transition:
2516+
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
2517+
height 220ms cubic-bezier(0.22, 1, 0.36, 1);
2518+
}
2519+
2520+
.sidebar-brand-mark img {
2521+
width: 100%;
2522+
height: 100%;
2523+
object-fit: cover;
2524+
}
2525+
2526+
.sidebar:hover .sidebar-brand-mark,
2527+
.sidebar:focus-within .sidebar-brand-mark {
2528+
width: 56px;
2529+
height: 56px;
2530+
}
2531+
24772532
.sidebar-footer {
24782533
margin-top: auto;
24792534
}

app/layout.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
22
import { Inter } from "next/font/google";
33
import "katex/dist/katex.min.css";
44
import "./globals.css";
5+
import { SiteSidebar } from "./site-sidebar";
6+
import { GlobalMobileNavToggle } from "./global-mobile-nav";
57

68
export const metadata: Metadata = {
79
title: "DBSMO Training Platform",
@@ -13,11 +15,13 @@ export const metadata: Metadata = {
1315

1416
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
1517

16-
export default function RootLayout({
18+
export default async function RootLayout({
1719
children,
1820
}: Readonly<{
1921
children: React.ReactNode;
2022
}>) {
23+
const sidebar = await SiteSidebar();
24+
2125
return (
2226
<html lang="en" className={inter.variable} suppressHydrationWarning>
2327
<head>
@@ -32,7 +36,7 @@ export default function RootLayout({
3236
}}
3337
/>
3438
</head>
35-
<body className={inter.className}>
39+
<body className={`${inter.className}${sidebar ? " has-site-sidebar" : ""}`}>
3640
<div className="confetti-triangles" aria-hidden="true">
3741
<span className="tri-1" />
3842
<span className="tri-2" />
@@ -47,7 +51,9 @@ export default function RootLayout({
4751
<span className="tri-11" />
4852
<span className="tri-12" />
4953
</div>
50-
{children}
54+
{sidebar}
55+
{sidebar ? <GlobalMobileNavToggle /> : null}
56+
<div className="site-content">{children}</div>
5157
</body>
5258
</html>
5359
);

app/site-sidebar-nav.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname } from "next/navigation";
5+
import {
6+
BarChart3,
7+
CheckCircle2,
8+
ClipboardList,
9+
FileJson,
10+
Gauge,
11+
ListChecks,
12+
MessageSquareWarning,
13+
PenLine,
14+
Settings,
15+
Swords,
16+
Target,
17+
Trophy,
18+
User,
19+
Users,
20+
type LucideIcon,
21+
} from "lucide-react";
22+
23+
type SidebarLink = {
24+
href: string;
25+
label: string;
26+
icon: keyof typeof ICON_MAP;
27+
match?: string;
28+
};
29+
30+
const ICON_MAP: Record<string, LucideIcon> = {
31+
Gauge,
32+
ClipboardList,
33+
Target,
34+
Swords,
35+
ListChecks,
36+
PenLine,
37+
FileJson,
38+
Users,
39+
BarChart3,
40+
MessageSquareWarning,
41+
CheckCircle2,
42+
User,
43+
Trophy,
44+
Settings,
45+
};
46+
47+
export function SiteSidebarNav({ links }: { links: SidebarLink[] }) {
48+
const pathname = usePathname() ?? "/";
49+
50+
return (
51+
<nav className="nav-list">
52+
{links.map((link) => {
53+
const Icon = ICON_MAP[link.icon];
54+
const matchPrefix = link.match ?? link.href;
55+
const isActive =
56+
pathname === link.href ||
57+
(matchPrefix !== "/" && pathname.startsWith(matchPrefix));
58+
return (
59+
<Link
60+
key={link.href}
61+
className={`nav-item${isActive ? " active" : ""}`}
62+
href={link.href}
63+
>
64+
<Icon size={18} />
65+
<span className="nav-label">{link.label}</span>
66+
</Link>
67+
);
68+
})}
69+
</nav>
70+
);
71+
}
72+
73+
export type { SidebarLink };

0 commit comments

Comments
 (0)