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.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user