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
223 lines
7 KiB
TypeScript
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>
|
|
);
|
|
}
|