Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/dashboard/components/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ export default function DashboardLayout({
return (
<div className="min-h-screen flex">
{initialGuildId ? <GuildRouteSync guildId={initialGuildId} /> : null}
<Sidebar session={session} />
<div className="flex-1 ml-64">
<Header title={title} subtitle={subtitle} />
<Sidebar session={session} isOpen={sidebarOpen} onClose={closeSidebar} />
<div className="flex-1 md:ml-64">
<Header
title={title}
subtitle={subtitle}
onMenuClick={toggleSidebar}
menuOpen={sidebarOpen}
/>
<main className="p-8">{children}</main>
</div>
</div>
Expand Down
52 changes: 43 additions & 9 deletions apps/dashboard/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,55 @@
export default function Header({
title,
subtitle,
onMenuClick,
menuOpen,
}: {
title: string;
subtitle?: string;
/** Opens the mobile sidebar drawer. Omit to hide the toggle button. */
onMenuClick?: () => void;
/** Whether the mobile sidebar drawer is currently open. */
menuOpen?: boolean;
}) {
return (
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-8 py-6 sticky top-0 z-10">
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">
{title}
</h1>
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-4 md:px-8 py-6 sticky top-0 z-10 flex items-center gap-4">
{onMenuClick && (
<button
type="button"
onClick={onMenuClick}
aria-label="Open menu"
aria-controls="dashboard-sidebar"
aria-expanded={menuOpen ?? false}
className="inline-flex items-center justify-center rounded-md p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white md:hidden"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
)}

{subtitle ? (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
{subtitle}
</p>
) : null}
<div>
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">
{title}
</h1>

{subtitle ? (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
{subtitle}
</p>
) : null}
</div>
</header>
);
}
70 changes: 48 additions & 22 deletions apps/dashboard/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
"use client";

import { useEffect, useRef } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { Session } from "@/lib/auth/session";
import { useOptionalGuild } from "@/lib/guild/GuildProvider";

const navItems = [
{ name: "Dashboard", href: "/dashboard", icon: "📊" },
{ name: "Passes", href: "/passes", icon: "🎫" },
{ name: "Guilds", href: "/guilds", icon: "🏰" },
{ name: "Members", href: "/members", icon: "👥" },
{ name: "Activity", href: "/activity", icon: "📋" },
{ name: "Integrations", href: "/integrations", icon: "🔌" },
{ name: "Settings", href: "/settings", icon: "⚙️" },
];
import { navItems, isNavItemActive } from "@/lib/nav-items";

/**
* Human-readable label + colour for each role,
Expand Down Expand Up @@ -61,11 +53,29 @@ export default function Sidebar({
const pathname = usePathname();
const router = useRouter();
const guildCtx = useOptionalGuild();
const closeButtonRef = useRef<HTMLButtonElement>(null);

const badge = session?.role
? ROLE_BADGE[session.role]
: null;

// Mobile drawer: close on Escape and move focus to the close
// button so keyboard users land somewhere sensible.
useEffect(() => {
if (!isOpen || !onClose) return;

closeButtonRef.current?.focus();

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);

const handleGuildChange = (nextId: string) => {
if (!guildCtx || nextId === guildCtx.guildId) {
return;
Expand All @@ -90,8 +100,19 @@ export default function Sidebar({
};

return (
<div
className={`
<>
{/* Mobile backdrop — click or Escape to close */}
{isOpen && onClose && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
aria-hidden="true"
onClick={onClose}
/>
)}

<div
id="dashboard-sidebar"
className={`
fixed
left-0
top-0
Expand All @@ -109,7 +130,7 @@ export default function Sidebar({
${isOpen ? "translate-x-0" : "-translate-x-full"}
md:translate-x-0
`}
>
>
{/* Sidebar header */}
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-4 dark:border-slate-700">
<div className="flex items-center gap-2 text-lg font-bold">
Expand All @@ -120,9 +141,10 @@ export default function Sidebar({
{/* Close button — visible only on mobile */}
{onClose && (
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="inline-flex items-center justify-center rounded-md p-1 text-slate-400 hover:bg-slate-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 md:hidden"
className="inline-flex items-center justify-center rounded-md p-1 text-slate-400 hover:bg-slate-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 md:hidden"
aria-label="Close sidebar"
>
<svg
Expand Down Expand Up @@ -159,7 +181,7 @@ export default function Sidebar({
onChange={(e) =>
handleGuildChange(e.target.value)
}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-white focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-slate-900"
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:bg-slate-900"
aria-label="Select guild"
>
{guildCtx.guilds.map((g) => (
Expand All @@ -171,27 +193,25 @@ export default function Sidebar({

<Link
href={`/guilds/${guildCtx.guildId}`}
className="mt-2 inline-block text-xs text-slate-400 transition-colors hover:text-primary-300"
className="mt-2 inline-block rounded text-xs text-slate-400 transition-colors hover:text-primary-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
>
Open guild overview →
</Link>
</div>
)}

{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4">
<nav className="flex-1 overflow-y-auto p-4" aria-label="Primary">
<ul className="space-y-2">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/" &&
pathname?.startsWith(item.href));
const isActive = isNavItemActive(pathname, item.href);

return (
<li key={item.name}>
<Link
href={item.href}
onClick={onClose}
aria-current={isActive ? "page" : undefined}
className={`
flex
items-center
Expand All @@ -200,6 +220,11 @@ export default function Sidebar({
px-4
py-3
transition-colors
focus:outline-none
focus-visible:ring-2
focus-visible:ring-primary-500
focus-visible:ring-offset-2
focus-visible:ring-offset-slate-900
${
isActive
? "bg-slate-800 font-medium text-primary-300"
Expand Down Expand Up @@ -239,6 +264,7 @@ export default function Sidebar({
</span>
</div>
)}
</div>
</div>
</>
);
}
30 changes: 30 additions & 0 deletions apps/dashboard/lib/nav-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface NavItem {
name: string;
href: string;
icon: string;
}

export const navItems: NavItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: "📊" },
{ name: "Passes", href: "/passes", icon: "🎫" },
{ name: "Guilds", href: "/guilds", icon: "🏰" },
{ name: "Members", href: "/members", icon: "👥" },
{ name: "Activity", href: "/activity", icon: "📋" },
{ name: "Integrations", href: "/integrations", icon: "🔌" },
{ name: "Settings", href: "/settings", icon: "⚙️" },
];

/**
* Whether a nav item should be treated as the active route.
* Matches the item's own path exactly, or a nested route under it
* (e.g. "/guilds" is active for "/guilds/abc123") — but not a
* sibling route that merely shares a prefix (e.g. "/passes" is not
* active for "/passes-archive").
*/
export function isNavItemActive(
pathname: string | null | undefined,
href: string,
): boolean {
if (!pathname) return false;
return pathname === href || pathname.startsWith(`${href}/`);
}
65 changes: 65 additions & 0 deletions apps/dashboard/test/nav-items.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";

import { isNavItemActive, navItems } from "../lib/nav-items";

/**
* nav-items.test.ts
*
* Tests for the pure active-route matching logic used by the dashboard
* sidebar to set aria-current="page" on the current nav link.
*/

describe("isNavItemActive", () => {
test("matches an exact route", () => {
assert.equal(isNavItemActive("/dashboard", "/dashboard"), true);
});

test("matches a nested route under the item", () => {
assert.equal(isNavItemActive("/guilds/abc123", "/guilds"), true);
});

test("does not match a sibling route sharing a prefix", () => {
assert.equal(isNavItemActive("/passes-archive", "/passes"), false);
});

test("does not match an unrelated route", () => {
assert.equal(isNavItemActive("/settings", "/passes"), false);
});

test("returns false for a null pathname", () => {
assert.equal(isNavItemActive(null, "/dashboard"), false);
});

test("returns false for an undefined pathname", () => {
assert.equal(isNavItemActive(undefined, "/dashboard"), false);
});

test("does not match the empty string pathname against a real route", () => {
assert.equal(isNavItemActive("", "/dashboard"), false);
});
});

describe("navItems", () => {
test("every primary dashboard route from issue #294 is present", () => {
const hrefs = navItems.map((item) => item.href);
for (const expected of [
"/dashboard",
"/passes",
"/guilds",
"/members",
"/activity",
"/settings",
]) {
assert.ok(hrefs.includes(expected), `missing nav item for ${expected}`);
}
});

test("every item has a non-empty name, href, and icon", () => {
for (const item of navItems) {
assert.ok(item.name.length > 0);
assert.ok(item.href.startsWith("/"));
assert.ok(item.icon.length > 0);
}
});
});