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
+8 -259
View File
@@ -1,261 +1,10 @@
"use client";
import { redirect } from "next/navigation";
import { useState, useMemo } from "react";
import { useCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems";
import { CallRow } from "@/components/CallRow";
import { useAuth } from "@/components/AuthProvider";
import type { CallRecord } from "@/lib/types";
const inputCls =
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white font-mono " +
"placeholder:text-gray-600 focus:outline-none focus:border-indigo-500 w-full";
function filterCalls(calls: CallRecord[], filters: Filters): CallRecord[] {
const q = filters.query.trim().toLowerCase();
const tgid = filters.tgid.trim();
return calls.filter((c) => {
// System filter
if (filters.systemId && c.system_id !== filters.systemId) return false;
// TGID filter (exact match on the number)
if (tgid && String(c.talkgroup_id ?? "") !== tgid) return false;
// Free-text: talkgroup name, node_id, transcript, tags
if (q) {
const hay = [
c.talkgroup_name ?? "",
c.node_id,
c.transcript ?? "",
c.transcript_corrected ?? "",
...(c.tags ?? []),
].join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
interface Filters {
query: string;
tgid: string;
systemId: string;
dateFrom: string;
dateTo: string;
}
const DEFAULT_FILTERS: Filters = {
query: "",
tgid: "",
systemId: "",
dateFrom: "",
dateTo: "",
};
function isActive(f: Filters) {
return f.query || f.tgid || f.systemId || f.dateFrom || f.dateTo;
}
export default function CallsPage() {
const [limitCount, setLimitCount] = useState(100);
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom + "T00:00:00") : undefined;
const dateTo = filters.dateTo ? new Date(filters.dateTo + "T23:59:59") : undefined;
const { calls, loading } = useCalls(limitCount, dateFrom, dateTo);
const { systems } = useSystems();
const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
const [showFilters, setShowFilters] = useState(false);
function set<K extends keyof Filters>(key: K, value: string) {
setFilters((f) => ({ ...f, [key]: value }));
}
const active = calls.filter((c) => c.status === "active");
const ended = calls.filter((c) => c.status === "ended");
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
const activeFilters = isActive(filters);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
<button
onClick={() => setShowFilters((v) => !v)}
className={`text-xs font-mono px-3 py-1.5 rounded-lg border transition-colors ${
activeFilters
? "border-indigo-600 bg-indigo-950 text-indigo-300"
: "border-gray-700 bg-gray-900 text-gray-400 hover:text-gray-200"
}`}
>
{showFilters ? "Hide filters" : "Filter"}
{activeFilters && " •"}
</button>
</div>
</div>
{/* Filter bar */}
{showFilters && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Text search */}
<div className="lg:col-span-2">
<label className="text-xs text-gray-500 block mb-1">Search (talkgroup, node, transcript, tags)</label>
<input
type="text"
value={filters.query}
onChange={(e) => set("query", e.target.value)}
placeholder="fire, Engine 5, dispatch…"
className={inputCls}
/>
</div>
{/* TGID */}
<div>
<label className="text-xs text-gray-500 block mb-1">Talkgroup ID</label>
<input
type="number"
value={filters.tgid}
onChange={(e) => set("tgid", e.target.value)}
placeholder="e.g. 9048"
className={inputCls}
/>
</div>
{/* System */}
<div>
<label className="text-xs text-gray-500 block mb-1">System</label>
<select
value={filters.systemId}
onChange={(e) => set("systemId", e.target.value)}
className={inputCls}
>
<option value="">All systems</option>
{systems.map((s) => (
<option key={s.system_id} value={s.system_id}>{s.name}</option>
))}
</select>
</div>
{/* Date from */}
<div>
<label className="text-xs text-gray-500 block mb-1">From date</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => set("dateFrom", e.target.value)}
className={inputCls}
/>
</div>
{/* Date to */}
<div>
<label className="text-xs text-gray-500 block mb-1">To date</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => set("dateTo", e.target.value)}
className={inputCls}
/>
</div>
</div>
{activeFilters && (
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-gray-500 font-mono">
{filtered.length} of {ended.length} calls match
</p>
<button
onClick={() => setFilters(DEFAULT_FILTERS)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
>
Clear all
</button>
</div>
)}
</div>
)}
{/* Live calls — never filtered */}
{active.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
Live ({active.length})
</h2>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{active.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))}
</tbody>
</table>
</div>
</section>
)}
{/* History */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
History{activeFilters && <span className="ml-2 text-indigo-400">({filtered.length} filtered)</span>}
</h2>
{loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p>
) : filtered.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">
{activeFilters ? "No calls match the current filters." : "No calls recorded yet."}
</p>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
</tr>
</thead>
<tbody>
{filtered.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))}
</tbody>
</table>
</div>
{ended.length >= limitCount && (
<button
onClick={() => setLimitCount((n) => n + 100)}
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300 font-mono transition-colors"
>
Load more
</button>
)}
</>
)}
</section>
</div>
);
// Destination for /calls is Archive (/search) per UI_REDESIGN.md §3/§5.4, but
// /search needs paged server-side search (order_by/limit/cursor on
// internal/firestore.py, a GET /calls/search route) that doesn't exist yet —
// tracked as UI_REDESIGN.md chunk 12, blocked on Gitea #17/#18. Until then
// this redirects to Incidents, same as the chunk 4 spec's interim.
export default function CallsPageRedirect() {
redirect("/incidents");
}
-170
View File
@@ -1,170 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
import { useCalls, useActiveCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems";
import { useActiveIncidents } from "@/lib/useIncidents";
import { NodeCard } from "@/components/NodeCard";
import { CallRow } from "@/components/CallRow";
import { NodeConfigModal } from "@/components/NodeConfigModal";
import { TypeBadge } from "@/components/IncidentBadges";
import { severityBadge, severityRank } from "@/lib/severity";
import { useState } from "react";
import type { NodeRecord, IncidentRecord } from "@/lib/types";
import { useAuth } from "@/components/AuthProvider";
import { PageHeader } from "@/components/ui/PageHeader";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<Card>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
</Card>
);
}
function fmtTime(iso: string) {
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) {
const router = useRouter();
return (
<Card hover className="cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<TypeBadge type={incident.type} />
{severityBadge(incident.severity)}
</div>
<p className="text-white text-sm font-semibold leading-snug line-clamp-2">{incident.title ?? "Untitled incident"}</p>
<p className="text-gray-500 text-xs font-mono mt-2">
{fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p>
</Card>
);
}
export default function DashboardPage() {
const { nodes, error: nodesError } = useNodes();
const { nodes: pending } = useUnconfiguredNodes();
const { calls, error: callsError } = useCalls(20);
const activeCalls = useActiveCalls();
const { systems, error: systemsError } = useSystems();
const activeIncidents = useActiveIncidents();
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
const onlineCount = nodes.filter((n) => n.status !== "offline").length;
const fsError = nodesError ?? callsError ?? systemsError;
// Worst-first: the incident that most needs a human's attention leads the panel.
const sortedIncidents = [...activeIncidents].sort(
(a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)
);
const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length;
return (
<div className="space-y-8">
<PageHeader
title="Dashboard"
badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
/>
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
{/* Pending config banner */}
{pending.length > 0 && (
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-lg p-4 flex items-center justify-between gap-3 flex-wrap">
<p className="text-indigo-300 text-sm font-mono">
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
</p>
<Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</Button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Active Incidents" value={activeIncidents.length} accent={activeIncidents.length > 0 ? "text-orange-400" : undefined} />
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
<StatCard label="Systems" value={systems.length} />
</div>
{/* Active incidents — the primary "what's happening" view */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Active Incidents</h2>
<Link href="/incidents" className="text-xs text-indigo-400 hover:text-indigo-300 font-mono transition-colors">
View all →
</Link>
</div>
{sortedIncidents.length === 0 ? (
<EmptyState
title="No active incidents"
description="Incidents appear here automatically as calls correlate into events."
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedIncidents.slice(0, 6).map((inc) => (
<IncidentSummaryCard key={inc.incident_id} incident={inc} />
))}
</div>
)}
</section>
{/* Nodes */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
{nodes.length === 0 ? (
<EmptyState title="No nodes registered yet" description="Deploy a field SDR node and it will show up here automatically." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((n) => (
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
))}
</div>
)}
</section>
{/* Recent calls */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
{calls.length === 0 ? (
<EmptyState title="No calls recorded yet" />
) : (
<Card padding="none" className="overflow-hidden overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
</tr>
</thead>
<tbody>
{calls.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))}
</tbody>
</table>
</Card>
)}
</section>
{configNode && (
<NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
)}
</div>
);
}
+5 -78
View File
@@ -1,80 +1,7 @@
"use client";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useNodes } from "@/lib/useNodes";
import { useActiveCalls } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
export default function MapPage() {
const { nodes, loading } = useNodes();
const activeCalls = useActiveCalls();
const incidents = useActiveIncidents();
const [kiosk, setKiosk] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
// Track when data last refreshed
useEffect(() => {
if (!loading) setLastUpdated(new Date());
}, [nodes, activeCalls, incidents, loading]);
// Kiosk mode: full-viewport fixed overlay sits above the sticky nav (z-40 → z-50)
if (kiosk) {
return (
<div className="fixed inset-0 z-50 bg-gray-950">
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={incidents}
lastUpdated={lastUpdated}
/>
<button
onClick={() => setKiosk(false)}
title="Exit fullscreen"
className="absolute bottom-[5.5rem] left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
</svg>
Exit fullscreen
</button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
<button
onClick={() => setKiosk(true)}
title="Fullscreen / kiosk mode"
className="text-xs font-mono text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1.5"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
</svg>
Fullscreen
</button>
</div>
{loading ? (
<div className="flex items-center justify-center h-[calc(100vh-10rem)] border border-gray-800 rounded-lg text-gray-600 font-mono text-sm">
Loading map…
</div>
) : (
<div className="w-full h-[calc(100vh-10rem)] border border-gray-800 rounded-lg overflow-hidden">
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={incidents}
lastUpdated={lastUpdated}
/>
</div>
)}
</div>
);
// The map is no longer a destination you navigate to — it's the product,
// and the product is the landing page. See UI_REDESIGN.md §3.
export default function MapPageRedirect() {
redirect("/");
}
+27 -1
View File
@@ -1,8 +1,13 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LinkButton } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { PLANS } from "@/lib/billing";
import { useAuth } from "@/components/AuthProvider";
const CAPABILITIES = [
{
@@ -37,7 +42,7 @@ const STEPS = [
{ n: "03", title: "Your team watches", body: "Incidents show up on the live map and dashboard with an AI summary, units on scene, and every related recording." },
];
export default function MarketingHomePage() {
function MarketingHomePage() {
return (
<div>
{/* Hero */}
@@ -139,3 +144,24 @@ export default function MarketingHomePage() {
</div>
);
}
/**
* "/" is marketing for a signed-out visitor and Live (the map) for anyone
* signed in — see UI_REDESIGN.md §3, "the map is the home screen". The Live
* screen itself lands in chunk 6; until then a signed-in, provisioned user
* is sent to /incidents rather than shown stale marketing copy. A user with
* no org_id yet still sees marketing (unchanged — that's the pre-redesign
* behaviour for an unprovisioned account, out of scope here).
*/
export default function HomePage() {
const { user, loading, orgId } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading || !user || !orgId) return;
router.replace("/incidents");
}, [loading, user, orgId, router]);
if (loading || (user && orgId)) return null;
return <MarketingHomePage />;
}
+18 -1
View File
@@ -45,7 +45,13 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
router.replace("/onboarding");
}, [loading, user, orgId, pathname, router]);
if (MARKETING_PATHS.has(pathname)) {
// "/" is marketing for a signed-out visitor, but the moment someone is
// signed in it's Live — the map is the home screen (UI_REDESIGN.md §3),
// not a marketing page. Every other marketing path stays marketing
// regardless of auth state.
const showMarketingChrome = MARKETING_PATHS.has(pathname) && !(pathname === "/" && user);
if (showMarketingChrome) {
return (
<>
<MarketingHeader />
@@ -55,6 +61,17 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
);
}
// Live ("/") is full-bleed under its own top bar, not the padded
// max-width container the rest of the app uses.
if (pathname === "/" && user) {
return (
<>
<Nav />
<main className="h-[calc(100vh-3.75rem)]">{children}</main>
</>
);
}
return (
<>
<Nav />
+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>