diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index b6285e6..ce3b55e 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -8,19 +8,77 @@ import type { Metadata } from "next"; */ export const metadata: Metadata = { robots: { index: false, follow: false } }; -import AppTopNav from "@/components/patterns/app-nav"; +import { AppSidebar, AppTopBar, type AppNavSection } from "@/components/patterns/app-sidebar"; +import { SessionTimeout } from "@/components/patterns/session-timeout"; import { requireOnboardedUser } from "@/lib/auth/guards"; +import { sessionLimitsFor } from "@/lib/auth/session-policy"; +/** Builder links every authenticated user gets. */ +const BUILDER_ITEMS = [ + { href: "/dashboard", label: "Dashboard" }, + { href: "/dashboard/hackathons", label: "My Hackathons" }, + { href: "/dashboard/teams", label: "Teams" }, + { href: "/dashboard/winnings", label: "Winnings" }, + { href: "/dashboard/intros", label: "Intros" }, + { href: "/dashboard/notifications", label: "Alerts" }, +]; + +/** Role surfaces, shown only while the user holds the grant (RBAC mirrors lib/auth/rbac). */ +const ROLE_SECTIONS: { role: "ORGANIZER" | "JUDGE" | "HIRING"; title: string; items: { href: string; label: string }[] }[] = [ + { + role: "ORGANIZER", + title: "Organize", + items: [ + { href: "/organizer", label: "Overview" }, + { href: "/organizer/organization", label: "Organization" }, + { href: "/organizer/verification", label: "Verification" }, + { href: "/organizer/hackathons/new", label: "New Hackathon" }, + ], + }, + { + role: "JUDGE", + title: "Judge", + items: [{ href: "/judge", label: "Assignments" }], + }, + { + role: "HIRING", + title: "Hiring", + items: [ + { href: "/hiring", label: "Talent" }, + { href: "/hiring/requests", label: "Requests" }, + ], + }, +]; + +const ACCOUNT_ITEMS = [ + { href: "/dashboard/profile", label: "Profile" }, + { href: "/settings", label: "Settings" }, +]; + +/** + * The authenticated app shell: the marketing ruled frame with a sidebar + * column inside it (plan §13.3: sidebar ≥ lg). Below 1024px the rules drop + * out and the top bar's drawer carries the same sections. + */ export default async function AppLayout({ children }: { children: React.ReactNode }) { const user = await requireOnboardedUser(); + const sections: AppNavSection[] = [ + { title: "Build", items: BUILDER_ITEMS }, + ...ROLE_SECTIONS.filter((section) => user.roles.includes(section.role)), + { title: "Account", items: ACCOUNT_ITEMS }, + ]; + const shellUser = { handle: user.handle }; + return ( -
-
-
-
-
-
+ {surfaceLabel ? {surfaceLabel} : null}
+
+ );
+}
+
+function AppNavSections({
+ sections,
+ navLabel,
+ onNavigate,
+}: {
+ sections: AppNavSection[];
+ navLabel: string;
+ onNavigate?: () => void;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Desktop sidebar: the left column of the ruled app frame (hidden below
+ * 1024px, where the top bar's drawer takes over). Its brand cell is the same
+ * height as the top bar, so one hairline runs across the whole frame.
+ */
+export function AppSidebar({
+ sections,
+ navLabel,
+ surfaceLabel,
+}: {
+ sections: AppNavSection[];
+ navLabel: string;
+ surfaceLabel?: string;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * The content column's top bar, aligned with the sidebar's brand cell. It
+ * always carries the theme switcher and the user menu; below 1024px it also
+ * shows the brand and a hamburger that opens the same sections in a drawer.
+ * The drawer + overlay are portaled to document.body: the bar has
+ * `backdrop-filter`, which makes it the containing block for `position:
+ * fixed` descendants (the same trap the marketing MobileNav documents).
+ */
+export function AppTopBar({
+ sections,
+ navLabel,
+ user,
+ surfaceLabel,
+}: {
+ sections: AppNavSection[];
+ navLabel: string;
+ user: AppShellUser;
+ surfaceLabel?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("keydown", onKeyDown);
+ document.documentElement.classList.add("overflow-hidden");
+
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ document.documentElement.classList.remove("overflow-hidden");
+ };
+ }, [open]);
+
+ return (
+