WORKFLOW-APP/components/NavBar.tsx
SJCoetz bb97fef453 feat: initialize full ysterbaard frontend application
Add complete production-ready Ysterbaard PocketBase registry browser frontend:
- Next.js 14 App Router with TypeScript, Tailwind CSS, and TanStack React Query
- Secure server-side PocketBase API proxy to avoid exposing superuser tokens
- Read-only directors and companies registry browse pages with search, filtering, and pagination
- Client onboarding workflow for creating and managing client submissions
- Docker container configuration for easy deployment
- All supporting UI components, utility functions, and configuration files
2026-07-23 19:10:47 +02:00

223 lines
7 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): a fixed bottom tab bar with inline-SVG icons (Overview
// · Directors · Companies · Onboarding). The second-row horizontal
// scroller from earlier turns has been replaced because the tab bar
// is the standard mobile-app pattern and reclaims vertical screen
// real estate.
//
// 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 type { ReactNode } from "react";
const LINKS = [
{ href: "/", label: "Overview", icon: IconHome },
{ href: "/directors", label: "Directors", icon: IconUsers },
{ href: "/companies", label: "Companies", icon: IconBuilding },
{ href: "/onboarding", label: "Onboarding", icon: IconClipboard }
] as const;
export default function NavBar() {
const pathname = usePathname() ?? "/";
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-20">
<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"
>
{LINKS.map((link) => {
const isActive = isActiveLink(pathname, link.href);
return (
<Link
key={link.href}
href={link.href}
data-od-id={`nav-${link.label.toLowerCase()}`}
className={
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors " +
(isActive
? "bg-surface-warm text-fg"
: "text-fg hover:bg-surface-warm")
}
>
{link.label}
</Link>
);
})}
</nav>
{/* Right-side desktop label. Hidden on mobile — the bottom
tab bar carries the brand context there. */}
<div className="hidden md:flex items-center text-xs text-muted">
<span>Registry Browser</span>
</div>
{/* Spacer for mobile so the brand lockup stays left-aligned
and the bottom-bar height is the only vertical change. */}
<div className="md:hidden" aria-hidden="true" />
</div>
</header>
{/* Mobile bottom tab bar */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 bg-surface border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Primary"
>
<ul className="container-x flex items-stretch justify-between h-16 px-2">
{LINKS.map((link) => {
const isActive = isActiveLink(pathname, link.href);
const Icon = link.icon;
return (
<li key={link.href} className="flex-1">
<Link
href={link.href}
aria-current={isActive ? "page" : undefined}
aria-label={link.label}
className={
"group flex flex-col items-center justify-center gap-1 h-full no-underline transition-colors " +
(isActive
? "text-accent"
: "text-muted hover:text-fg")
}
>
<Icon
className={
"w-5 h-5 transition-transform " +
(isActive ? "scale-110" : "scale-100")
}
active={isActive}
/>
<span className="text-[11px] font-semibold tracking-wide">
{link.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
</>
);
}
function isActiveLink(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(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 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="6" y="4" width="12" height="17" rx="1.5" />
<path d="M9 4h6v3H9z" fill="currentColor" stroke="none" />
<path d="M9 12h6M9 16h4" />
</svg>
);
}