The mobile bottom bar rendered all 15 routes into a single row. On a 390px iPhone that works out at ~17px per tab: the icons overlap, every label truncates to a single letter and the tap targets sit far below the 44px minimum, so the tabs could not be read or hit reliably. Mobile now uses a hamburger in the header that opens a full-screen menu listing every section, grouped (Main / CIPC & BizPortal / SARS / Practice). The panel locks background scroll, closes on Escape or on navigation, and carries the theme toggle so the mobile chrome keeps parity with the desktop cluster. Desktop had the same problem in a different shape. The full 15-link row needed 1752px inside a 1280px container, so the page scrolled sideways even at 1920px (measured scrollWidth 2065 vs a 1920 viewport) and the right-hand cluster was pushed off screen. The inline nav now carries six primary links plus a "More" dropdown, which fits exactly. - NavBar: mobile tab bar replaced by the header menu panel; desktop nav reduced to primary links + More dropdown; grouped list shared by both so the two surfaces cannot drift apart. - RouteChrome: dropped the pb-24 md:pb-0 padding that only existed to clear the old bottom bar. - .gitignore / Workflow/.dockerignore: ignore .next-stale* so a stale build directory cannot bloat the Docker build context.
734 lines
23 KiB
TypeScript
734 lines
23 KiB
TypeScript
// components/NavBar.tsx
|
|
// Top-of-page navigation. Logo lockup on the left, primary nav
|
|
// center, secondary status on the right. Sticky on scroll with the
|
|
// signature hairline border-bottom.
|
|
//
|
|
// Mobile (<md): no tab bar. The header carries a hamburger that opens
|
|
// a full-screen menu panel listing every section, grouped. A previous
|
|
// iteration tried a fixed bottom tab bar, but all 15 routes were
|
|
// crammed into a 390px phone width (≈17px per tab, labels cut to one
|
|
// letter) — unusable. The full-screen menu keeps every section one
|
|
// tap away with full-size rows.
|
|
//
|
|
// Desktop (md+): primary links inline, everything else behind a
|
|
// "More" dropdown. The full 15-link row needed 1752px inside a
|
|
// 1280px container, which pushed the right-hand cluster off-screen
|
|
// and gave the whole page a horizontal scrollbar.
|
|
//
|
|
// Icons use stroke-currentColor at 1.6px per the design system
|
|
// (anti-slop rule: emoji and gradient icons are forbidden). Brand
|
|
// mark "Y" pill in the top-left stays on all viewports.
|
|
|
|
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useTheme } from "next-themes";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
|
|
const LINKS = [
|
|
{ href: "/", label: "Overview", icon: IconHome },
|
|
{ href: "/notes", label: "Notes", icon: IconNotepad },
|
|
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
|
|
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
|
|
{ href: "/cipc", label: "CIPC", icon: IconSearch },
|
|
{ href: "/cipc-2", label: "CIPC2.0", icon: IconSearch, badge: "test" },
|
|
{ href: "/cipc-report", label: "CIPC Report", icon: IconCheckSquare },
|
|
{ href: "/sars-returns", label: "SARS Returns", icon: IconReceipt },
|
|
{ href: "/sars-registrations", label: "SARS Registrations", icon: IconRegistry },
|
|
{ href: "/tasks", label: "Tasks", icon: IconClipboard },
|
|
{ href: "/directors", label: "Directors", icon: IconUsers },
|
|
{ href: "/companies", label: "Companies", icon: IconBuilding },
|
|
{ href: "/groups", label: "Groups", icon: IconTag },
|
|
{ href: "/compliance", label: "Compliance", icon: IconCheckSquare },
|
|
{ href: "/resolutions", label: "Resolutions", icon: IconDocument },
|
|
] as const;
|
|
|
|
type LinkItem = (typeof LINKS)[number];
|
|
|
|
const LINK_BY_HREF: Record<string, LinkItem> = Object.fromEntries(
|
|
LINKS.map((l) => [l.href, l]),
|
|
) as Record<string, LinkItem>;
|
|
|
|
/** Sections that stay one click away in the desktop header. */
|
|
const DESKTOP_PRIMARY = [
|
|
"/",
|
|
"/notes",
|
|
"/companies",
|
|
"/directors",
|
|
"/tasks",
|
|
"/compliance",
|
|
] as const;
|
|
|
|
/** Every section, grouped — the shared source of truth for the mobile
|
|
* menu panel and the desktop "More" dropdown. */
|
|
const MENU_GROUPS: { title: string; hrefs: string[] }[] = [
|
|
{ title: "Main", hrefs: ["/", "/tasks", "/directors", "/companies"] },
|
|
{
|
|
title: "CIPC & BizPortal",
|
|
hrefs: ["/bizportal", "/bizportal-2", "/cipc", "/cipc-2", "/cipc-report"],
|
|
},
|
|
{ title: "SARS", hrefs: ["/sars-returns", "/sars-registrations"] },
|
|
{ title: "Practice", hrefs: ["/notes", "/groups", "/compliance", "/resolutions"] },
|
|
];
|
|
|
|
export default function NavBar() {
|
|
const pathname = usePathname() ?? "/";
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
const moreRef = useRef<HTMLDivElement | null>(null);
|
|
const closeRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
// Navigating away closes whichever surface was open.
|
|
useEffect(() => {
|
|
setMenuOpen(false);
|
|
setMoreOpen(false);
|
|
}, [pathname]);
|
|
|
|
// The mobile panel is a modal surface: lock the page behind it.
|
|
useEffect(() => {
|
|
if (!menuOpen) return;
|
|
const previous = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
closeRef.current?.focus();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setMenuOpen(false);
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.body.style.overflow = previous;
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [menuOpen]);
|
|
|
|
// The desktop dropdown closes on an outside click or Escape.
|
|
useEffect(() => {
|
|
if (!moreOpen) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setMoreOpen(false);
|
|
};
|
|
const onPointer = (e: MouseEvent) => {
|
|
if (!moreRef.current?.contains(e.target as Node)) setMoreOpen(false);
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
document.addEventListener("mousedown", onPointer);
|
|
return () => {
|
|
document.removeEventListener("keydown", onKey);
|
|
document.removeEventListener("mousedown", onPointer);
|
|
};
|
|
}, [moreOpen]);
|
|
|
|
const primaryLinks = LINKS.filter((l) =>
|
|
(DESKTOP_PRIMARY as readonly string[]).includes(l.href),
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<header
|
|
data-od-id="navbar"
|
|
className="sticky top-0 z-30 bg-surface border-b border-border"
|
|
>
|
|
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
|
|
<Link
|
|
href="/"
|
|
data-od-id="brand-mark"
|
|
className="flex items-center gap-2 no-underline"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="inline-flex items-center justify-center w-8 h-8 rounded-pill bg-accent text-accent-on font-bold text-sm"
|
|
>
|
|
Y
|
|
</span>
|
|
<span className="text-lg font-bold text-fg tracking-tight">
|
|
Workflow
|
|
</span>
|
|
</Link>
|
|
|
|
<nav
|
|
className="hidden md:flex items-center gap-2"
|
|
aria-label="Primary"
|
|
>
|
|
{primaryLinks.map((link) => {
|
|
const isActive = isActiveLink(pathname, link.href);
|
|
return (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
data-od-id={`nav-${link.label.toLowerCase().replace(/\s+/g, "-")}`}
|
|
className={
|
|
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors inline-flex items-center gap-2 " +
|
|
(isActive
|
|
? "bg-surface-warm text-fg"
|
|
: "text-fg hover:bg-surface-warm")
|
|
}
|
|
>
|
|
<span>{link.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
|
|
{/* Overflow dropdown — the desktop counterpart of the
|
|
mobile menu panel. */}
|
|
<div className="relative" ref={moreRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMoreOpen((v) => !v)}
|
|
aria-expanded={moreOpen}
|
|
aria-haspopup="true"
|
|
aria-label="More sections"
|
|
data-od-id="desktop-more-button"
|
|
className={
|
|
"px-4 py-2 rounded-md text-sm font-semibold transition-colors inline-flex items-center gap-1.5 " +
|
|
(moreOpen || isOverflowActive(pathname)
|
|
? "bg-surface-warm text-fg"
|
|
: "text-fg hover:bg-surface-warm")
|
|
}
|
|
>
|
|
More
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={
|
|
"w-4 h-4 transition-transform " + (moreOpen ? "rotate-180" : "")
|
|
}
|
|
aria-hidden="true"
|
|
>
|
|
<polyline points="6,9 12,15 18,9" />
|
|
</svg>
|
|
</button>
|
|
|
|
{moreOpen ? (
|
|
<div
|
|
data-od-id="desktop-more-panel"
|
|
className="absolute right-0 top-full mt-2 w-[340px] max-h-[70vh] overflow-y-auto bg-surface border border-border rounded-lg shadow-raised p-2 z-40"
|
|
>
|
|
<GroupedList onNavigate={() => setMoreOpen(false)} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Right-side desktop cluster: label + theme toggle. Hidden
|
|
on mobile — the menu panel carries both. */}
|
|
<div className="hidden md:flex items-center gap-3">
|
|
<span className="hidden lg:inline text-xs text-muted">
|
|
Registry Browser
|
|
</span>
|
|
<ThemeToggle />
|
|
</div>
|
|
|
|
{/* Mobile: hamburger opens the full-screen menu. */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setMenuOpen(true)}
|
|
aria-label="Open menu"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={menuOpen}
|
|
data-od-id="mobile-menu-button"
|
|
className="md:hidden inline-flex items-center justify-center w-10 h-10 -mr-2 rounded-md text-fg"
|
|
>
|
|
<IconMenu className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Mobile full-screen menu panel. */}
|
|
{menuOpen ? (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Main menu"
|
|
data-od-id="mobile-menu-panel"
|
|
className="md:hidden fixed inset-0 z-50 bg-bg overflow-y-auto"
|
|
>
|
|
<div className="sticky top-0 bg-surface border-b border-border">
|
|
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
|
|
<Link href="/" className="flex items-center gap-2 no-underline">
|
|
<span
|
|
aria-hidden="true"
|
|
className="inline-flex items-center justify-center w-8 h-8 rounded-pill bg-accent text-accent-on font-bold text-sm"
|
|
>
|
|
Y
|
|
</span>
|
|
<span className="text-lg font-bold text-fg tracking-tight">
|
|
Workflow
|
|
</span>
|
|
</Link>
|
|
<button
|
|
ref={closeRef}
|
|
type="button"
|
|
onClick={() => setMenuOpen(false)}
|
|
aria-label="Close menu"
|
|
data-od-id="mobile-menu-close"
|
|
className="w-10 h-10 -mr-2 inline-flex items-center justify-center rounded-md text-fg"
|
|
>
|
|
<IconClose className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<GroupedList onNavigate={() => setMenuOpen(false)} />
|
|
|
|
<div className="container-x flex items-center justify-between py-6 pb-[calc(env(safe-area-inset-bottom)+24px)]">
|
|
<span className="text-xs text-muted">Registry Browser</span>
|
|
<ThemeToggle />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Grouped list of every section, shared by the mobile menu panel and
|
|
* the desktop "More" dropdown.
|
|
*/
|
|
function GroupedList({ onNavigate }: { onNavigate?: () => void }) {
|
|
const pathname = usePathname() ?? "/";
|
|
return (
|
|
<div className="px-4 pb-2 md:px-0">
|
|
{MENU_GROUPS.map((group) => (
|
|
<div key={group.title} className="pt-5">
|
|
<p className="text-[11px] font-bold uppercase tracking-wider text-meta mb-1 px-1">
|
|
{group.title}
|
|
</p>
|
|
<ul>
|
|
{group.hrefs.map((href) => {
|
|
const link = LINK_BY_HREF[href];
|
|
if (!link) return null;
|
|
const Icon = link.icon;
|
|
const active = isActiveLink(pathname, href);
|
|
return (
|
|
<li key={href}>
|
|
<Link
|
|
href={href}
|
|
onClick={onNavigate}
|
|
className="flex items-center gap-3 py-2.5 no-underline rounded-md px-1"
|
|
>
|
|
<span
|
|
className={
|
|
"w-9 h-9 rounded-md inline-flex items-center justify-center shrink-0 " +
|
|
(active
|
|
? "bg-accent text-accent-on"
|
|
: "bg-surface-warm text-muted")
|
|
}
|
|
>
|
|
<Icon className="w-5 h-5" active={active} />
|
|
</span>
|
|
<span
|
|
className={
|
|
"text-[15px] font-semibold " +
|
|
(active ? "text-accent" : "text-fg")
|
|
}
|
|
>
|
|
{link.label}
|
|
</span>
|
|
{"badge" in link && link.badge ? (
|
|
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
|
|
{link.badge}
|
|
</span>
|
|
) : null}
|
|
<IconChevronRight className="w-4 h-4 ml-auto text-meta shrink-0" />
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isActiveLink(pathname: string, href: string): boolean {
|
|
if (href === "/") return pathname === "/";
|
|
return pathname === href || pathname.startsWith(href + "/");
|
|
}
|
|
|
|
/** True when the current route lives behind the "More" dropdown. */
|
|
function isOverflowActive(pathname: string): boolean {
|
|
return LINKS.some(
|
|
(l) =>
|
|
!(DESKTOP_PRIMARY as readonly string[]).includes(l.href) &&
|
|
isActiveLink(pathname, l.href),
|
|
);
|
|
}
|
|
|
|
/* ─── Icon set (inline SVG, stroke-currentColor, 1.6px stroke) ──── */
|
|
|
|
interface IconProps {
|
|
className?: string;
|
|
/** Active state hints — used to swap fill behavior on the home icon
|
|
* and to thicken the stroke on outlined icons. Always defaults to
|
|
* the brand "muted" rendering since the active state is owned by
|
|
* the parent (text color + scale already encodes it). */
|
|
active?: boolean;
|
|
}
|
|
|
|
function IconHome({ className, active }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill={active ? "currentColor" : "none"}
|
|
stroke="currentColor"
|
|
strokeWidth={1.6}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M3 11.5L12 4l9 7.5" />
|
|
<path d="M5 10.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1v-9.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconUsers({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="9" cy="9" r="3.2" />
|
|
<path d="M3.5 19c.7-3 3-4.6 5.5-4.6S13.8 16 14.5 19" />
|
|
<circle cx="16.5" cy="9" r="2.6" />
|
|
<path d="M15.2 14.4c2 0 4 1.4 4.6 4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconBuilding({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="4" y="3" width="16" height="18" rx="1.5" />
|
|
<path d="M8 7h2M8 11h2M8 15h2M14 7h2M14 11h2M14 15h2" />
|
|
<path d="M10 21v-3h4v3" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconDocument({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14,2 14,8 20,8" />
|
|
<line x1="9" y1="13" x2="15" y2="13" />
|
|
<line x1="9" y1="17" x2="13" y2="17" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconClipboard({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="8" y="2" width="8" height="4" rx="1" />
|
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
|
<line x1="9" y1="13" x2="15" y2="13" />
|
|
<line x1="9" y1="17" x2="12" y2="17" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconSearch({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconTag({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
|
<line x1="7" y1="7" x2="7.01" y2="7" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconCheckSquare({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<path d="M9 12l2 2 4-4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tax-receipt glyph for the SARS Returns tab. Deliberately a torn-
|
|
* edge receipt (zig-zag bottom) to distinguish it from IconDocument
|
|
* (which is a sealed page) and IconClipboard (which has a clip).
|
|
*/
|
|
function IconReceipt({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M5 3h14v18l-2-1.6L15 21l-2-1.6L11 21l-2-1.6L7 21l-2-1.6V3z" />
|
|
<line x1="9" y1="8" x2="15" y2="8" />
|
|
<line x1="9" y1="12" x2="15" y2="12" />
|
|
<line x1="9" y1="16" x2="13" y2="16" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Registry / ID-badge glyph for the SARS Registrations tab. A card with
|
|
* two horizontal ruled lines and a small "stamp" circle in the corner —
|
|
* visually distinct from IconReceipt (torn-edge paper), IconNotepad
|
|
* (folded top-right page), IconDocument (sealed page), and
|
|
* IconClipboard (task list with a clip).
|
|
*/
|
|
function IconRegistry({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="3.5" y="4.5" width="14" height="13" rx="1.6" />
|
|
<line x1="6.5" y1="8.5" x2="14.5" y2="8.5" />
|
|
<line x1="6.5" y1="11.5" x2="14.5" y2="11.5" />
|
|
<line x1="6.5" y1="14" x2="11.5" y2="14" />
|
|
<circle cx="17.5" cy="17" r="3" />
|
|
<path d="M16.2 17l1 1 1.6-1.8" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Notepad glyph for the Notes tab. Rounded rectangle with a horizontal
|
|
* ruled line and a folded top-right corner — distinguishes it from
|
|
* IconClipboard (task list with a clip) and IconDocument (sealed page).
|
|
*/
|
|
function IconNotepad({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8z" />
|
|
<polyline points="14,2 14,8 19,8" />
|
|
<line x1="8" y1="13" x2="16" y2="13" />
|
|
<line x1="8" y1="17" x2="14" y2="17" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Hamburger glyph for the mobile menu button. */
|
|
function IconMenu({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<line x1="3.5" y1="7" x2="20.5" y2="7" />
|
|
<line x1="3.5" y1="12" x2="20.5" y2="12" />
|
|
<line x1="3.5" y1="17" x2="20.5" y2="17" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Close glyph for the mobile menu panel. */
|
|
function IconClose({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<line x1="5" y1="5" x2="19" y2="19" />
|
|
<line x1="19" y1="5" x2="5" y2="19" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Right-chevron used as the list affordance in the grouped lists. */
|
|
function IconChevronRight({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<polyline points="9.5,5 16.5,12 9.5,19" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/* ─── Theme toggle ────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Sun/moon button. Uses next-themes' `resolvedTheme` so system
|
|
* preference is honoured. The icon shown represents the mode you'd
|
|
* switch TO (moon in light, sun in dark).
|
|
*
|
|
* `mounted` guard avoids SSR/CSR mismatch — `useTheme` returns
|
|
* undefined on the first render. Until mounted, we render a neutral
|
|
* placeholder so the button's box doesn't pop in late.
|
|
*/
|
|
function ThemeToggle() {
|
|
const [mounted, setMounted] = useState(false);
|
|
const { resolvedTheme, setTheme } = useTheme();
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
const isDark = mounted && resolvedTheme === "dark";
|
|
const next = isDark ? "light" : "dark";
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setTheme(next)}
|
|
aria-label={mounted ? `Switch to ${next} mode` : "Toggle theme"}
|
|
title={`Switch to ${next} mode`}
|
|
data-od-id="theme-toggle"
|
|
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-fg hover:bg-surface-warm transition-colors"
|
|
>
|
|
{mounted ? (
|
|
isDark ? (
|
|
<IconSun className="w-5 h-5" />
|
|
) : (
|
|
<IconMoon className="w-5 h-5" />
|
|
)
|
|
) : (
|
|
// Neutral placeholder slot — same dimensions so layout doesn't shift.
|
|
<span className="w-5 h-5 block" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function IconSun({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconMoon({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
|
</svg>
|
|
);
|
|
}
|