src/components/site-header.tsx
Linked repository file from this build.
"use client";
import { can } from "@/lib/auth/scope";
import { NavigationLink as Link } from "@/components/navigation-link";
import { usePathname } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useAppearance } from "./appearance-provider";
import {
BookOpen,
ChartColumn,
ClipboardCheck,
Download,
FolderOpen,
Mail,
Menu,
PanelLeftClose,
Upload,
type LucideIcon,
} from "lucide-react";
import { useReviewNavigation } from "@/features/reviews/provider";
import { useSession } from "@/lib/auth/client";
import { AppearanceControl } from "./appearance-control";
import { IrisMark } from "./brand";
import { NotificationBell } from "./notification-bell";
import { HeaderSearch } from "./header-search";
import { QueuePane } from "./queue-pane";
import { UserControl } from "./user-control";
/**
* One navigation surface owns links, queues and account controls in every layout.
* Hidden rails share a hover boundary; icon rails expand only when pinned.
*/
export function SiteNav() {
const { actor, isSignedIn } = useSession();
const { navigate, navigationTarget } = useReviewNavigation();
const pathname = usePathname();
const activePath = navigationTarget?.split(/[?#]/)[0] ?? pathname;
const { sidebarOpen: open, setSidebarOpen: setOpen, appearance, ready } = useAppearance();
const [leave, setLeave] = useState(false);
const root = useRef<HTMLElement>(null);
useLayoutEffect(() => {
const nav = root.current;
if (!nav) return;
const content = nav.querySelector<HTMLElement>(".app-nav-content")!;
const tools = nav.querySelector<HTMLElement>(".app-tools")!;
const shell = nav.closest<HTMLElement>(".app-shell")!;
const measure = () => {
shell.style.setProperty(
"--header-side-width",
`${Math.max(content.getBoundingClientRect().width, tools.getBoundingClientRect().width) + 24}px`,
);
shell.style.setProperty("--sidebar-width", `${nav.getBoundingClientRect().width}px`);
};
const labels = () => {
// Estimate the full navigation from its actual labels; reserve a useful centered search.
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
context.font = getComputedStyle(content).font;
const textWidth = [...content.querySelectorAll(".app-nav-label, .app-brand-text")].reduce(
(sum, label) => sum + context.measureText(label.textContent || "").width + 58,
0,
);
content.dataset.labels = String(
window.innerWidth >= Math.max(1440, 2 * (textWidth + 24) + 640),
);
measure();
};
window.addEventListener("resize", labels);
labels();
const observer = new ResizeObserver(measure);
observer.observe(content);
observer.observe(tools);
observer.observe(nav);
measure();
return () => {
observer.disconnect();
window.removeEventListener("resize", labels);
};
}, [actor.role, appearance.font, appearance.fontSize]);
const senior = can(actor, "report");
const links: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/reviews", label: "Reviews", icon: FolderOpen },
...(can(actor, "approvalQueue")
? [{ href: "/approval-queue", label: "Approval Queue", icon: ClipboardCheck }]
: []),
...(actor.role === "admin" ? [{ href: "/search", label: "Mail desk", icon: Mail }] : []),
...(can(actor, "export") ? [{ href: "/exports", label: "Exports", icon: Upload }] : []),
...(senior
? [
{ href: "/imports", label: "Imports", icon: Download },
{ href: "/reports", label: "Reports", icon: ChartColumn },
{ href: "/policy", label: "Policy", icon: ClipboardCheck },
]
: []),
];
useEffect(() => {
if (!open) return;
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("keydown", escape);
};
}, [open, setOpen]);
return (
<nav
ref={root}
aria-label="Main navigation"
className="app-nav"
data-open={open || undefined}
data-leave={leave || undefined}
onPointerLeave={() => setLeave(false)}
>
<button
type="button"
className="app-menu"
aria-expanded={open}
aria-pressed={open}
aria-label={open ? "Unpin sidebar" : "Pin sidebar open"}
title={open ? "Unpin sidebar" : "Pin sidebar open"}
aria-controls="app-nav-list"
onClick={(event) => {
if (open) {
setLeave(true);
setOpen(false);
event.currentTarget.blur();
return;
}
setLeave(false);
setOpen(true);
}}
>
{open ? <PanelLeftClose aria-hidden /> : <Menu aria-hidden />}
</button>
<span className="app-iris">IRIS</span>
<div className="app-nav-content">
<Link
href="/dashboard"
onClick={(event) => {
event.preventDefault();
void navigate("/dashboard");
}}
className="app-brand"
aria-label="Home"
aria-current={activePath === "/dashboard" ? "page" : undefined}
>
<span className="app-brand-mark" aria-hidden>
<IrisMark className="size-4" />
</span>
<span className="app-brand-text">Home</span>
</Link>
<ul id="app-nav-list" className="app-nav-list">
{links.map((link) => {
const current = activePath.startsWith(link.href);
return (
<li key={link.href}>
<Link
href={link.href}
onClick={(event) => {
event.preventDefault();
void navigate(link.href);
}}
aria-current={current ? "page" : undefined}
aria-label={link.label}
data-tip={link.label}
data-tip-side="right"
className="app-nav-link"
>
<link.icon aria-hidden />
<span className="app-nav-label">{link.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
{ready && appearance.layout === "desk" && <QueuePane />}
<div className="app-tools">
<Link
href="/docs"
onClick={(event) => {
event.preventDefault();
void navigate("/docs");
}}
aria-current={activePath.startsWith("/docs") ? "page" : undefined}
aria-label="Documentation"
data-tip="Documentation"
data-tip-side="right"
className="app-nav-link"
>
<BookOpen aria-hidden />
</Link>
{!isSignedIn && <AppearanceControl />}
{!isSignedIn && <NotificationBell />}
<UserControl />
</div>
</nav>
);
}
/** Search and account tools. Pressing `/` outside a field focuses the search. */
export function SiteToolbar({ title = "Iris" }: { title?: string }) {
useEffect(() => {
const shortcut = (event: KeyboardEvent) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable], [role=combobox]")) return;
const search = document.querySelector<HTMLInputElement>('input[aria-label="Search reviews"]');
if (!search) return;
event.preventDefault();
search.focus();
search.select();
};
document.addEventListener("keydown", shortcut);
return () => document.removeEventListener("keydown", shortcut);
}, []);
return (
<header aria-label={title} className="app-header">
<div className="app-search">
<HeaderSearch />
</div>
</header>
);
}