"use client"; import { useState, useEffect } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { ChevronDown, Menu, X } from "lucide-react"; import { cn } from "@/lib/utils"; export type NavLeaf = { href: string; label: string }; export type NavItem = | { kind: "link"; href: string; label: string; icon: React.ReactNode } | { kind: "group"; label: string; icon: React.ReactNode; children: NavLeaf[] }; // A group is active when the current path is one of its children, or sits under one // of them. A child of "/" only matches exactly — otherwise every route would count as // "under" the dashboard. function isGroupActive(item: Extract, pathname: string): boolean { return item.children.some((c) => c.href === "/" ? pathname === "/" : pathname === c.href || pathname.startsWith(`${c.href}/`), ); } // The desktop rail and the mobile drawer render the same list from this one // component, so the two navs can never drift apart. function NavList({ items, onNavigate }: { items: NavItem[]; onNavigate?: () => void }) { const pathname = usePathname(); return ( ); } /** * Desktop rail only. It is `hidden lg:block`, so it contributes ZERO width below * `lg` and must stay a pure sibling of `
` — anything visible on mobile * rendered in this flex row steals width from the page. The mobile trigger lives * in the header instead; see `MobileNav`. */ export function Sidebar({ items }: { items: NavItem[] }) { return ( ); } /** * Mobile nav: the hamburger plus the drawer it opens. Rendered INSIDE the header, * which is the whole point — the old version put the button in the content flex * row, where its 36px was subtracted from every page below `lg` and indented the * entire site on phones and tablets. * * The header is full-bleed, so a 44px trigger there costs no content width. * `-ml-3` pulls the hit area into the header's own `px-4`, which lands the icon's * left edge exactly on the logo's (16px) without changing the logo's position. */ export function MobileNav({ items }: { items: NavItem[] }) { const pathname = usePathname(); const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); // Close drawer on navigation useEffect(() => { setOpen(false); }, [pathname]); // Scroll lock, plus the SSR gate for the portal below — `document` does not // exist while this component is server-rendered. useEffect(() => { setMounted(true); document.body.style.overflow = open ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [open]); return ( <> {/* ⚠️ THE PORTAL IS LOAD-BEARING — do not "simplify" it back to inline JSX. The header carries `backdrop-blur`, and a filter/backdrop-filter makes an element the containing block for its `position: fixed` descendants. Inline, this drawer would size and position itself against the 64px header box instead of the viewport — a 64px-tall sliver pinned under the header. Portalling to returns it to the root stacking context. This is the same trap class as MachineCombobox: a portal is NOT a DOM descendant of the component that made it, so any hit-testing that reaches across this boundary (click-away, focus traps) will read as "outside". */} {mounted && createPortal( <> {open && (
setOpen(false)} /> )} , document.body, )} ); } function LeafLink({ href, label, icon, active, onClick, }: { href: string; label: string; icon: React.ReactNode; active: boolean; onClick?: () => void; }) { return ( {icon} {label} ); } function Group({ item, active }: { item: Extract; active: boolean }) { const [open, setOpen] = useState(active); const pathname = usePathname(); return (
{open && (
{item.children.map((child) => { const childActive = pathname === child.href; return ( {child.label} ); })}
)}
); }