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:
@@ -1,261 +1,10 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
// Destination for /calls is Archive (/search) per UI_REDESIGN.md §3/§5.4, but
|
||||||
import { useCalls } from "@/lib/useCalls";
|
// /search needs paged server-side search (order_by/limit/cursor on
|
||||||
import { useSystems } from "@/lib/useSystems";
|
// internal/firestore.py, a GET /calls/search route) that doesn't exist yet —
|
||||||
import { CallRow } from "@/components/CallRow";
|
// tracked as UI_REDESIGN.md chunk 12, blocked on Gitea #17/#18. Until then
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
// this redirects to Incidents, same as the chunk 4 spec's interim.
|
||||||
import type { CallRecord } from "@/lib/types";
|
export default function CallsPageRedirect() {
|
||||||
|
redirect("/incidents");
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +1,7 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
// The map is no longer a destination you navigate to — it's the product,
|
||||||
import dynamic from "next/dynamic";
|
// and the product is the landing page. See UI_REDESIGN.md §3.
|
||||||
import { useNodes } from "@/lib/useNodes";
|
export default function MapPageRedirect() {
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
redirect("/");
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { LinkButton } from "@/components/ui/Button";
|
import { LinkButton } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { PLANS } from "@/lib/billing";
|
import { PLANS } from "@/lib/billing";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
|
||||||
const CAPABILITIES = [
|
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." },
|
{ 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
@@ -139,3 +144,24 @@ export default function MarketingHomePage() {
|
|||||||
</div>
|
</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 />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
|||||||
router.replace("/onboarding");
|
router.replace("/onboarding");
|
||||||
}, [loading, user, orgId, pathname, router]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<MarketingHeader />
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
|
|||||||
@@ -9,32 +9,20 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
|
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
|
||||||
|
|
||||||
// Links visible to all authenticated roles (viewer+)
|
// The five destinations, per UI_REDESIGN.md §3. Everything else (Settings,
|
||||||
const viewerLinks = [
|
// Admin, Trips, Profile) lives behind the avatar — it's operator plumbing,
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
// not a peer of Incidents.
|
||||||
{ href: "/calls", label: "Calls" },
|
const productLinks = [
|
||||||
|
{ href: "/", label: "Live" },
|
||||||
{ href: "/incidents", label: "Incidents" },
|
{ href: "/incidents", label: "Incidents" },
|
||||||
{ href: "/map", label: "Map" },
|
{ href: "/calls", label: "Archive" },
|
||||||
{ href: "/alerts", label: "Alerts" },
|
{ href: "/watch", label: "Watch" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Trips is an internal utility feature, not a tenant-scoped product surface
|
// Network (nodes/systems/enrollment/bot tokens — "my equipment") stays
|
||||||
// (see [[trips-feature-intentional]] and SAAS_PLAN.md B7) — shown only to
|
// scoped to operators and admins, matching the write-access boundary the
|
||||||
// the founding org, matching routers/trips.py's own gating.
|
// pages behind it have always had.
|
||||||
const tripsLink = { href: "/trips", label: "Trips" };
|
const networkLink = { href: "/network", label: "Network" };
|
||||||
|
|
||||||
// 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function SunIcon() {
|
function SunIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -92,38 +80,39 @@ export function Nav() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allLinks = [
|
const navLinks = [...productLinks, ...(isAdmin || isOperator ? [networkLink] : [])];
|
||||||
...viewerLinks,
|
const showTrips = orgId === FOUNDING_ORG_ID || isAdmin;
|
||||||
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
|
const showSettings = isAdmin || isOrgOwner;
|
||||||
...(isAdmin || isOperator ? operatorLinks : []),
|
|
||||||
...(isAdmin ? adminLinks : []),
|
function isActive(href: string) {
|
||||||
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
|
if (href === "/") return pathname === "/";
|
||||||
];
|
return pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
function navLinkClass(href: string) {
|
function navLinkClass(href: string) {
|
||||||
return `text-sm font-mono transition-colors shrink-0 ${
|
return `text-sm font-medium transition-colors shrink-0 ${
|
||||||
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
|
isActive(href) ? "text-ink" : "text-ink-muted hover:text-ink-2"
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Main bar */}
|
||||||
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
|
<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 */}
|
{/* Desktop links */}
|
||||||
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
|
<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)}>
|
<Link key={href} href={href} className={navLinkClass(href)}>
|
||||||
{label}
|
{label}
|
||||||
{label === "Nodes" && pending.length > 0 && (
|
{label === "Network" && 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">
|
<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}
|
{pending.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
{label === "Watch" && 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">
|
<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}
|
{unackedAlerts.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -135,20 +124,20 @@ export function Nav() {
|
|||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
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"}
|
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
</button>
|
</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">
|
<div className="hidden md:block relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setProfileMenuOpen((v) => !v)}
|
onClick={() => setProfileMenuOpen((v) => !v)}
|
||||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
|
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
|
||||||
pathname.startsWith("/profile") || profileMenuOpen
|
pathname.startsWith("/profile") || profileMenuOpen
|
||||||
? "bg-indigo-600 text-white"
|
? "bg-accent text-white"
|
||||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
: "bg-raised text-ink-2 hover:brightness-110"
|
||||||
}`}
|
}`}
|
||||||
title="Account"
|
title="Account"
|
||||||
>
|
>
|
||||||
@@ -159,26 +148,53 @@ export function Nav() {
|
|||||||
<>
|
<>
|
||||||
{/* Click-away backdrop */}
|
{/* Click-away backdrop */}
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setProfileMenuOpen(false)} />
|
<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
|
<Link
|
||||||
href="/profile"
|
href="/profile"
|
||||||
onClick={() => setProfileMenuOpen(false)}
|
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
|
Profile
|
||||||
</Link>
|
</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
|
<button
|
||||||
onClick={handleRefreshClaims}
|
onClick={handleRefreshClaims}
|
||||||
disabled={refreshing}
|
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"
|
title="Pick up a role or org change made server-side, without signing out"
|
||||||
>
|
>
|
||||||
{refreshing ? "Refreshing…" : "Refresh access"}
|
{refreshing ? "Refreshing…" : "Refresh access"}
|
||||||
</button>
|
</button>
|
||||||
<div className="border-t border-gray-800 my-1" />
|
<div className="border-t border-line my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
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
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
@@ -190,7 +206,7 @@ export function Nav() {
|
|||||||
{/* Hamburger (mobile) */}
|
{/* Hamburger (mobile) */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen((v) => !v)}
|
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"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
{mobileOpen ? (
|
{mobileOpen ? (
|
||||||
@@ -208,49 +224,64 @@ export function Nav() {
|
|||||||
|
|
||||||
{/* Mobile drawer */}
|
{/* Mobile drawer */}
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
<div className="md:hidden border-t border-line bg-page px-4 py-3 flex flex-col gap-1">
|
||||||
{allLinks.map(({ href, label }) => (
|
{navLinks.map(({ href, label }) => (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
className={`py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||||
pathname.startsWith(href) ? "text-white" : "text-gray-500"
|
isActive(href) ? "text-ink" : "text-ink-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{label === "Nodes" && pending.length > 0 && (
|
{label === "Network" && 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">
|
<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}
|
{pending.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
{label === "Watch" && 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">
|
<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}
|
{unackedAlerts.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/profile"
|
href="/profile"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
className={`py-2 text-sm font-medium transition-colors ${
|
||||||
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
|
pathname.startsWith("/profile") ? "text-ink" : "text-ink-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Link>
|
</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
|
<button
|
||||||
onClick={() => { setMobileOpen(false); handleRefreshClaims(); }}
|
onClick={() => { setMobileOpen(false); handleRefreshClaims(); }}
|
||||||
disabled={refreshing}
|
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"}
|
{refreshing ? "Refreshing…" : "Refresh access"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMobileOpen(false); handleSignOut(); }}
|
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
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user