Frontend redesign chunk 4: navigation and routing
Build & Deploy / Build & push images (push) Successful in 4m21s
Build & Deploy / Deploy to VM (push) Failing after 11m50s

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.
This commit is contained in:
Logan Cusano
2026-08-19 23:01:13 -04:00
parent 8fdedee25b
commit eaae452d4e
6 changed files with 151 additions and 571 deletions
+93 -62
View File
@@ -9,32 +9,20 @@ import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider";
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
// Links visible to all authenticated roles (viewer+)
const viewerLinks = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/calls", label: "Calls" },
// 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: "/map", label: "Map" },
{ href: "/alerts", label: "Alerts" },
{ href: "/calls", label: "Archive" },
{ href: "/watch", label: "Watch" },
];
// Trips is an internal utility feature, not a tenant-scoped product surface
// (see [[trips-feature-intentional]] and SAAS_PLAN.md B7) — shown only to
// the founding org, matching routers/trips.py's own gating.
const tripsLink = { href: "/trips", label: "Trips" };
// Additional links for operators and admins
const operatorLinks = [
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/tokens", label: "Tokens" },
];
// Platform-admin-only link. Settings is handled separately below — it's
// customer-facing for org owners too, not admin-only (SAAS_PLAN.md B7).
const adminLinks = [
{ href: "/admin", label: "Admin" },
];
// 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 (
@@ -92,38 +80,39 @@ export function Nav() {
}
}
const allLinks = [
...viewerLinks,
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []),
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
];
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-mono transition-colors shrink-0 ${
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
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-gray-800 bg-gray-950/95 backdrop-blur">
<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">
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
<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">
{allLinks.map(({ href, label }) => (
{navLinks.map(({ href, label }) => (
<Link key={href} href={href} className={navLinkClass(href)}>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{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 === "Alerts" && unackedAlerts.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{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>
)}
@@ -135,20 +124,20 @@ export function Nav() {
{/* Theme toggle */}
<button
onClick={toggle}
className="text-gray-500 hover:text-gray-300 transition-colors"
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) */}
{/* 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-indigo-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
? "bg-accent text-white"
: "bg-raised text-ink-2 hover:brightness-110"
}`}
title="Account"
>
@@ -159,26 +148,53 @@ export function Nav() {
<>
{/* Click-away backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setProfileMenuOpen(false)} />
<div className="absolute right-0 mt-2 w-48 bg-gray-900 border border-gray-800 rounded-lg shadow-lg z-50 py-1 font-mono text-sm">
<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-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
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-gray-300 hover:bg-gray-800 hover:text-white transition-colors disabled:opacity-50"
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-gray-800 my-1" />
<div className="border-t border-line my-1" />
<button
onClick={handleSignOut}
className="w-full text-left px-3 py-2 text-red-500 hover:bg-gray-800 hover:text-red-400 transition-colors"
className="w-full text-left px-3 py-2 text-sev-major hover:bg-raised transition-colors"
>
Sign out
</button>
@@ -190,7 +206,7 @@ export function Nav() {
{/* Hamburger (mobile) */}
<button
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
className="md:hidden text-ink-2 hover:text-ink transition-colors p-1"
aria-label="Toggle menu"
>
{mobileOpen ? (
@@ -208,49 +224,64 @@ export function Nav() {
{/* Mobile drawer */}
{mobileOpen && (
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
{allLinks.map(({ href, label }) => (
<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-mono transition-colors flex items-center gap-2 ${
pathname.startsWith(href) ? "text-white" : "text-gray-500"
className={`py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
isActive(href) ? "text-ink" : "text-ink-muted"
}`}
>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{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 === "Alerts" && unackedAlerts.length > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{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-gray-800 pt-3 mt-1 flex flex-col gap-1">
<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-mono transition-colors flex items-center gap-2 ${
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
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 font-mono text-gray-500 text-left disabled:opacity-50"
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 font-mono text-red-500 text-left"
className="py-2 text-sm text-sev-major text-left"
>
Sign out
</button>