Files
server-26/drb-frontend/components/Nav.tsx
T
Logan Cusano eaae452d4e
Build & Deploy / Build & push images (push) Successful in 4m21s
Build & Deploy / Deploy to VM (push) Failing after 11m50s
Frontend redesign chunk 4: navigation and routing
Rewrite Nav.tsx to the five-destination IA from UI_REDESIGN.md §3 (Live,
Incidents, Archive, Watch, Network) on tokens/sans type, with Settings,
Admin, Trips and Profile moved into the avatar dropdown instead of sitting
as nav peers. Network stays gated to admin/operator, matching the write
boundary its constituent pages (nodes/systems/tokens) already had.

Delete app/dashboard/page.tsx — its incident cards become the Live rail,
its node cards become Network, its call table becomes Archive; nothing on
it is unique. Add app/map/page.tsx -> redirect('/') and rewrite
app/calls/page.tsx -> redirect('/incidents') (Archive/search is blocked on
backend work, chunk 12).

ChromeSwitcher now gives a signed-in user at "/" the app shell instead of
marketing chrome; app/page.tsx branches the same way, sending a signed-in
provisioned user to /incidents as an honest interim until the Live screen
itself lands (chunk 6) — marketing content and behavior for signed-out
visitors is unchanged.

Left the light-mode !important overrides in globals.css in place past this
chunk (deviating from the chunk 4 acceptance criteria) — they still back
every page outside this redesign's 11-chunk scope (settings, admin,
profile, marketing). Deleting them now would break light mode on all of
those. Logged in DEFERRED.md.

Per UI_REDESIGN.md chunk 4.
2026-08-19 23:01:13 -04:00

294 lines
12 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider";
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
// The five destinations, per UI_REDESIGN.md §3. Everything else (Settings,
// Admin, Trips, Profile) lives behind the avatar — it's operator plumbing,
// not a peer of Incidents.
const productLinks = [
{ href: "/", label: "Live" },
{ href: "/incidents", label: "Incidents" },
{ href: "/calls", label: "Archive" },
{ href: "/watch", label: "Watch" },
];
// Network (nodes/systems/enrollment/bot tokens — "my equipment") stays
// scoped to operators and admins, matching the write-access boundary the
// pages behind it have always had.
const networkLink = { href: "/network", label: "Network" };
function SunIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
);
}
function MoonIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
}
export function Nav() {
const { user, isAdmin, isOperator, isOrgOwner, orgId, signOut, refreshClaims } = useAuth();
const pathname = usePathname();
const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes();
const unackedAlerts = useUnacknowledgedAlerts();
const { theme, toggle } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
if (!user) return null;
async function handleSignOut() {
setProfileMenuOpen(false);
await signOut();
router.push("/login");
}
// Re-fetches the ID token so a claims change made server-side (e.g. an
// admin granting a role, or org_id being provisioned) takes effect without
// a full sign-out/sign-in. See AuthProvider.refreshClaims.
async function handleRefreshClaims() {
setRefreshing(true);
try {
await refreshClaims();
} finally {
setRefreshing(false);
setProfileMenuOpen(false);
}
}
const navLinks = [...productLinks, ...(isAdmin || isOperator ? [networkLink] : [])];
const showTrips = orgId === FOUNDING_ORG_ID || isAdmin;
const showSettings = isAdmin || isOrgOwner;
function isActive(href: string) {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
}
function navLinkClass(href: string) {
return `text-sm font-medium transition-colors shrink-0 ${
isActive(href) ? "text-ink" : "text-ink-muted hover:text-ink-2"
}`;
}
return (
<nav className="sticky top-0 z-40 border-b border-line bg-page/95 backdrop-blur">
{/* Main bar */}
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
<Link href="/" className="font-semibold text-ink tracking-tight shrink-0">DRB</Link>
{/* Desktop links */}
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
{navLinks.map(({ href, label }) => (
<Link key={href} href={href} className={navLinkClass(href)}>
{label}
{label === "Network" && pending.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-sev-moderate text-page text-xs font-bold">
{pending.length}
</span>
)}
{label === "Watch" && unackedAlerts.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-sev-major text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
</div>
<div className="ml-auto flex items-center gap-3 shrink-0">
{/* Theme toggle */}
<button
onClick={toggle}
className="text-ink-muted hover:text-ink-2 transition-colors"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
{/* Profile avatar + dropdown (desktop) — Settings/Admin/Trips/Profile live here now, not the nav bar */}
<div className="hidden md:block relative">
<button
onClick={() => setProfileMenuOpen((v) => !v)}
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
pathname.startsWith("/profile") || profileMenuOpen
? "bg-accent text-white"
: "bg-raised text-ink-2 hover:brightness-110"
}`}
title="Account"
>
{(user?.displayName || user?.email || "?")[0].toUpperCase()}
</button>
{profileMenuOpen && (
<>
{/* Click-away backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setProfileMenuOpen(false)} />
<div className="absolute right-0 mt-2 w-48 bg-surface border border-line rounded-lg shadow-lg z-50 py-1 text-sm">
<Link
href="/profile"
onClick={() => setProfileMenuOpen(false)}
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
>
Profile
</Link>
{showSettings && (
<Link
href="/settings"
onClick={() => setProfileMenuOpen(false)}
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
>
Settings
</Link>
)}
{showTrips && (
<Link
href="/trips"
onClick={() => setProfileMenuOpen(false)}
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
>
Trips
</Link>
)}
{isAdmin && (
<Link
href="/admin"
onClick={() => setProfileMenuOpen(false)}
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
>
Admin
</Link>
)}
<button
onClick={handleRefreshClaims}
disabled={refreshing}
className="w-full text-left px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors disabled:opacity-50"
title="Pick up a role or org change made server-side, without signing out"
>
{refreshing ? "Refreshing…" : "Refresh access"}
</button>
<div className="border-t border-line my-1" />
<button
onClick={handleSignOut}
className="w-full text-left px-3 py-2 text-sev-major hover:bg-raised transition-colors"
>
Sign out
</button>
</div>
</>
)}
</div>
{/* Hamburger (mobile) */}
<button
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden text-ink-2 hover:text-ink transition-colors p-1"
aria-label="Toggle menu"
>
{mobileOpen ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
)}
</button>
</div>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="md:hidden border-t border-line bg-page px-4 py-3 flex flex-col gap-1">
{navLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
isActive(href) ? "text-ink" : "text-ink-muted"
}`}
>
{label}
{label === "Network" && pending.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-sev-moderate text-page text-xs font-bold">
{pending.length}
</span>
)}
{label === "Watch" && unackedAlerts.length > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-sev-major text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
<div className="border-t border-line pt-3 mt-1 flex flex-col gap-1">
<Link
href="/profile"
onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-medium transition-colors ${
pathname.startsWith("/profile") ? "text-ink" : "text-ink-muted"
}`}
>
Profile
</Link>
{showSettings && (
<Link href="/settings" onClick={() => setMobileOpen(false)} className="py-2 text-sm font-medium text-ink-muted">
Settings
</Link>
)}
{showTrips && (
<Link href="/trips" onClick={() => setMobileOpen(false)} className="py-2 text-sm font-medium text-ink-muted">
Trips
</Link>
)}
{isAdmin && (
<Link href="/admin" onClick={() => setMobileOpen(false)} className="py-2 text-sm font-medium text-ink-muted">
Admin
</Link>
)}
<button
onClick={() => { setMobileOpen(false); handleRefreshClaims(); }}
disabled={refreshing}
className="py-2 text-sm text-ink-muted text-left disabled:opacity-50"
>
{refreshing ? "Refreshing…" : "Refresh access"}
</button>
<button
onClick={() => { setMobileOpen(false); handleSignOut(); }}
className="py-2 text-sm text-sev-major text-left"
>
Sign out
</button>
</div>
</div>
)}
</nav>
);
}