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.
294 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|