From 53965e1a19d68810f4117bf730a235aedda8bdfc Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 16 Aug 2026 19:34:47 -0400 Subject: [PATCH] Rebuild the frontend as a product rather than an internal tool The UI worked but read as an operator console: no public face, no way to describe or sell the thing, and no account surface beyond the node list. This adds the missing halves and reorganises what was already there around the incident, which is the unit of value the rest of the pipeline is built to produce. A shared design system replaces per-page styling: components/ui (Button, Card, Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the Tailwind config, and light-mode tokens in globals.css. The existing html:not(.dark) remap mechanism is extended rather than replaced -- a parallel theming system would have been two sources of truth for the same colours. Public marketing pages (/, /features, /pricing, /faq) load without a session. middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX redirect and is still NOT an authorisation boundary, which the comment there says explicitly. Real enforcement is unchanged and still lives server-side in c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher instead of by route group, because a route group would have collided on / and forced most of app/ to move for no behavioural gain. Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and lib/apiKeys.ts define the data model and the screens consume it, but every mutating call throws with a message naming the backend route that has to exist first, and the sample data is labelled as sample. Nothing here can charge anyone or mint a real credential -- picking a payment processor and holding its keys is a decision for a human, and a half-wired checkout is worse than an obviously absent one. The severity work from the c2-core change lands here too. severity is now a filter and sort dimension on the incident list rather than decoration, since a busy dispatch channel is only readable if you can collapse it to moderate and above. routine gets a muted treatment because it is the majority of traffic, legacy "unknown" still renders nothing, and TypeBadge handles the new "other" incident type. Severity rendering moved into lib/severity.tsx so the incident list, incident detail and call rows cannot drift apart. Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and admin. They already share the palette and stay coherent, and rewriting them would have buried the parts that actually needed to change. No colour tokens were renamed, so nothing regressed there. Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification was possible and none was done. No new environment variables. --- drb-frontend/app/dashboard/page.tsx | 106 ++++-- drb-frontend/app/faq/page.tsx | 91 +++++ drb-frontend/app/features/page.tsx | 105 ++++++ drb-frontend/app/globals.css | 33 ++ drb-frontend/app/incidents/[id]/page.tsx | 20 +- drb-frontend/app/incidents/page.tsx | 356 +++++++++--------- drb-frontend/app/layout.tsx | 9 +- drb-frontend/app/login/page.tsx | 7 +- drb-frontend/app/page.tsx | 142 ++++++- drb-frontend/app/pricing/page.tsx | 101 +++++ drb-frontend/app/settings/api-keys/page.tsx | 155 ++++++++ drb-frontend/app/settings/billing/page.tsx | 214 +++++++++++ drb-frontend/app/settings/layout.tsx | 54 +++ drb-frontend/app/settings/members/page.tsx | 197 ++++++++++ drb-frontend/app/settings/nodes/page.tsx | 127 +++++++ .../app/settings/organization/page.tsx | 104 +++++ drb-frontend/app/settings/page.tsx | 5 + drb-frontend/components/CallRow.tsx | 5 + drb-frontend/components/ChromeSwitcher.tsx | 37 ++ drb-frontend/components/IncidentBadges.tsx | 21 ++ drb-frontend/components/Nav.tsx | 1 + .../components/marketing/MarketingFooter.tsx | 23 ++ .../components/marketing/MarketingHeader.tsx | 96 +++++ drb-frontend/components/ui/Badge.tsx | 28 ++ drb-frontend/components/ui/Button.tsx | 76 ++++ drb-frontend/components/ui/Card.tsx | 47 +++ drb-frontend/components/ui/EmptyState.tsx | 29 ++ drb-frontend/components/ui/PageHeader.tsx | 24 ++ drb-frontend/components/ui/Skeleton.tsx | 27 ++ drb-frontend/lib/apiKeys.ts | 73 ++++ drb-frontend/lib/billing.ts | 235 ++++++++++++ drb-frontend/lib/severity.tsx | 36 ++ drb-frontend/lib/types.ts | 2 + drb-frontend/middleware.ts | 12 + drb-frontend/tailwind.config.ts | 22 ++ 35 files changed, 2395 insertions(+), 225 deletions(-) create mode 100644 drb-frontend/app/faq/page.tsx create mode 100644 drb-frontend/app/features/page.tsx create mode 100644 drb-frontend/app/pricing/page.tsx create mode 100644 drb-frontend/app/settings/api-keys/page.tsx create mode 100644 drb-frontend/app/settings/billing/page.tsx create mode 100644 drb-frontend/app/settings/layout.tsx create mode 100644 drb-frontend/app/settings/members/page.tsx create mode 100644 drb-frontend/app/settings/nodes/page.tsx create mode 100644 drb-frontend/app/settings/organization/page.tsx create mode 100644 drb-frontend/app/settings/page.tsx create mode 100644 drb-frontend/components/ChromeSwitcher.tsx create mode 100644 drb-frontend/components/IncidentBadges.tsx create mode 100644 drb-frontend/components/marketing/MarketingFooter.tsx create mode 100644 drb-frontend/components/marketing/MarketingHeader.tsx create mode 100644 drb-frontend/components/ui/Badge.tsx create mode 100644 drb-frontend/components/ui/Button.tsx create mode 100644 drb-frontend/components/ui/Card.tsx create mode 100644 drb-frontend/components/ui/EmptyState.tsx create mode 100644 drb-frontend/components/ui/PageHeader.tsx create mode 100644 drb-frontend/components/ui/Skeleton.tsx create mode 100644 drb-frontend/lib/apiKeys.ts create mode 100644 drb-frontend/lib/billing.ts create mode 100644 drb-frontend/lib/severity.tsx diff --git a/drb-frontend/app/dashboard/page.tsx b/drb-frontend/app/dashboard/page.tsx index 6201c9d..5bee5d2 100644 --- a/drb-frontend/app/dashboard/page.tsx +++ b/drb-frontend/app/dashboard/page.tsx @@ -1,21 +1,52 @@ "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 } from "@/lib/types"; +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 ( -
+

{label}

{value}

-
+ + ); +} + +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 ( + router.push(`/incidents/${incident.incident_id}`)}> +
+ + {severityBadge(incident.severity)} +
+

{incident.title ?? "Untitled incident"}

+

+ {fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} +

+
); } @@ -25,6 +56,7 @@ export default function DashboardPage() { const { calls, error: callsError } = useCalls(20); const activeCalls = useActiveCalls(); const { systems, error: systemsError } = useSystems(); + const activeIncidents = useActiveIncidents(); const [configNode, setConfigNode] = useState(null); const { isAdmin } = useAuth(); @@ -33,44 +65,66 @@ export default function DashboardPage() { const fsError = nodesError ?? callsError ?? systemsError; - return ( -
-

Dashboard

+ // 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; - {fsError && ( -
-

Firestore error: {fsError}

-
- )} + return ( +
+ 0 && {notableIncidentCount} moderate+ active} + /> + + {fsError && } {/* Pending config banner */} {pending.length > 0 && ( -
+

{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.

- +
)} {/* Stats */}
+ 0 ? "text-orange-400" : undefined} /> 0 ? "text-orange-400" : undefined} /> -
+ {/* Active incidents — the primary "what's happening" view */} +
+
+

Active Incidents

+ + View all → + +
+ {sortedIncidents.length === 0 ? ( + + ) : ( +
+ {sortedIncidents.slice(0, 6).map((inc) => ( + + ))} +
+ )} +
+ {/* Nodes */}

Nodes

{nodes.length === 0 ? ( -

No nodes registered yet.

+ ) : (
{nodes.map((n) => ( @@ -84,9 +138,9 @@ export default function DashboardPage() {

Recent Calls

{calls.length === 0 ? ( -

No calls recorded yet.

+ ) : ( -
+ @@ -104,16 +158,12 @@ export default function DashboardPage() { ))}
-
+ )}
{configNode && ( - setConfigNode(null)} - /> + setConfigNode(null)} /> )}
); diff --git a/drb-frontend/app/faq/page.tsx b/drb-frontend/app/faq/page.tsx new file mode 100644 index 0000000..f6eb4a0 --- /dev/null +++ b/drb-frontend/app/faq/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { Badge } from "@/components/ui/Badge"; +import { LinkButton } from "@/components/ui/Button"; + +const FAQS: { q: string; a: string }[] = [ + { + q: "What hardware do I need to run a node?", + a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.", + }, + { + q: "What's the difference between a 'call' and an 'incident'?", + a: "A call is a single radio transmission. An incident is the thing you actually care about — a pursuit, a fire, an accident — built by correlating related calls together, sometimes across multiple talkgroups or nodes. Incidents are the primary view; calls are the evidence behind them.", + }, + { + q: "Does DRB do the transcription and AI work itself, or is that a separate cost?", + a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.", + }, + { + q: "Can I listen to live radio traffic without opening the dashboard?", + a: "Yes — the Discord bot can join a voice channel and relay live audio from any of your nodes, so your team can listen without a separate scanner app.", + }, + { + q: "How does node ownership and team access work?", + a: "Admins have full access. Operators are scoped to a specific list of nodes they own — they see and manage only those. Viewers get read-only access to everything the org exposes. You manage all of this from Settings → Members.", + }, + { + q: "What happens if I go over my plan's node or seat limit?", + a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.", + }, + { + q: "How long is call and incident history kept?", + a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.", + }, + { + q: "Is DMR supported?", + a: "Not yet — DMR is on the roadmap but the current release only decodes P25 and analog trunked systems.", + }, +]; + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + +export default function FaqPage() { + const [openIndex, setOpenIndex] = useState(0); + + return ( +
+
+ FAQ +

Frequently asked questions

+

Can't find what you're looking for? Sign in and reach out from your account.

+
+ +
+ {FAQS.map((item, i) => { + const open = openIndex === i; + return ( +
+ + {open && ( +

{item.a}

+ )} +
+ ); + })} +
+ +
+ Get started +
+
+ ); +} diff --git a/drb-frontend/app/features/page.tsx b/drb-frontend/app/features/page.tsx new file mode 100644 index 0000000..446fd11 --- /dev/null +++ b/drb-frontend/app/features/page.tsx @@ -0,0 +1,105 @@ +import { Card } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; +import { LinkButton } from "@/components/ui/Button"; + +const SECTIONS = [ + { + eyebrow: "Correlation", + title: "Calls become incidents", + body: + "The correlation engine groups related transmissions — across talkgroups and even across nodes — into a single incident. A pursuit renders as a path through every checkin point heard while it moved; a structure fire or accident renders as a pin at the location dispatch gave.", + points: [ + "Hybrid rule + LLM correlation with a cheap/smart consensus tiebreak", + "Distance, timing, shared units, and talkgroup signals all feed the match", + "Every call keeps its correlation debug trail for admins to audit", + ], + }, + { + eyebrow: "AI pipeline", + title: "Transcription and entity extraction", + body: + "Every recorded call is transcribed and scanned for the details that matter — units on scene, vehicles, and locations — so an incident reads like a dispatch briefing instead of a stack of raw audio.", + points: [ + "Automatic speech-to-text on every call", + "Scene & entity extraction feeds the correlator and the incident summary", + "AI-generated incident summaries, regenerable on demand", + ], + }, + { + eyebrow: "Situational awareness", + title: "Live map, full history", + body: + "Glance at the map to see what's active right now, or scrub back through history to review how a specific incident unfolded — every linked call, in order, with playback.", + points: [ + "Real-time node and incident map", + "Per-incident call timeline with audio playback", + "Configurable alert rules that post to Discord on keyword or talkgroup match", + ], + }, + { + eyebrow: "Field hardware", + title: "Field SDR nodes", + body: + "Lightweight edge nodes run OP25/GNU Radio against a P25 or analog trunked system and stream decoded audio to your account. Deploy one node to cover a town, or a whole network across a region.", + points: [ + "P25 and analog trunked systems supported", + "Per-node hardware tuning (gain, PPM, antenna) persists independently of system assignment", + "Node health, call activity, and configuration all visible from the dashboard", + ], + }, + { + eyebrow: "Team", + title: "Discord voice relay & role-scoped access", + body: + "The Discord bot relays live radio audio into a voice channel so your team can listen along without a separate app, and doubles as a lightweight utility bot for team coordination.", + points: [ + "Live audio relay per node, on demand", + "Admin / operator / viewer roles, with operators scoped to the nodes they own", + "Discord account linking for in-Discord commands", + ], + }, +]; + +export default function FeaturesPage() { + return ( +
+
+ Features +

Everything between the radio and the map

+

+ DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription, + correlation, mapping, and a live relay — end to end. +

+
+ +
+ {SECTIONS.map((s) => ( +
+
+

{s.eyebrow}

+

{s.title}

+

{s.body}

+
+ +
    + {s.points.map((p) => ( +
  • + + {p} +
  • + ))} +
+
+
+ ))} +
+ +
+

See it running on your own traffic

+
+ Get started +
+
+
+ ); +} diff --git a/drb-frontend/app/globals.css b/drb-frontend/app/globals.css index e9ae644..b0e35ae 100644 --- a/drb-frontend/app/globals.css +++ b/drb-frontend/app/globals.css @@ -115,3 +115,36 @@ html:not(.dark) input::placeholder, html:not(.dark) textarea::placeholder { color: #94a3b8; } + +/* ── Marketing/product surface additions (2026-08 overhaul) ───────────────── + * Same pattern as above: components use hardcoded dark-palette Tailwind + * classes, remapped here for light mode instead of dark: prefixes. + * Only new classes introduced by the marketing pages / settings shell live + * below — everything else reuses the palette already mapped above. + */ + +/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */ +html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; } +html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; } +html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; } +html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; } +html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; } +html:not(.dark) .bg-yellow-600\/10 { background-color: rgba(202,138,4,0.08) !important; } +html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !important; } + +/* Marketing hero background — subtle radial glow, brand-neutral in both themes */ +.marketing-hero-bg { + background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%); +} +html:not(.dark) .marketing-hero-bg { + background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%); +} + +/* Skeleton loading shimmer */ +@keyframes skeleton-pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} +.skeleton { + animation: skeleton-pulse 1.6s ease-in-out infinite; +} diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx index 130de19..011541a 100644 --- a/drb-frontend/app/incidents/[id]/page.tsx +++ b/drb-frontend/app/incidents/[id]/page.tsx @@ -10,26 +10,11 @@ import { useAuth } from "@/components/AuthProvider"; import { CallRow } from "@/components/CallRow"; import { c2api } from "@/lib/c2api"; import type { IncidentRecord } from "@/lib/types"; +import { TypeBadge } from "@/components/IncidentBadges"; +import { severityBadge } from "@/lib/severity"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); -const TYPE_COLORS: Record = { - fire: "bg-red-900 text-red-300", - police: "bg-blue-900 text-blue-300", - ems: "bg-yellow-900 text-yellow-300", - accident: "bg-orange-900 text-orange-300", - other: "bg-gray-800 text-gray-300", -}; - -function TypeBadge({ type }: { type: string | null }) { - const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other; - return ( - - {type ?? "other"} - - ); -} - function StatusBadge({ status }: { status: IncidentRecord["status"] }) { return ( + {severityBadge(incident.severity)}

{incident.title ?? "Incident"} diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index d5aa8d3..2ea5fca 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -1,49 +1,42 @@ "use client"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/components/AuthProvider"; import { useIncidents } from "@/lib/useIncidents"; import { c2api } from "@/lib/c2api"; import type { IncidentRecord } from "@/lib/types"; -import { useState } from "react"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { Badge } from "@/components/ui/Badge"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { SkeletonCard } from "@/components/ui/Skeleton"; +import { severityBadge, severityRank } from "@/lib/severity"; +import { TypeBadge } from "@/components/IncidentBadges"; -const TYPE_COLORS: Record = { - fire: "bg-red-900 text-red-300", - police: "bg-blue-900 text-blue-300", - ems: "bg-yellow-900 text-yellow-300", - accident: "bg-orange-900 text-orange-300", - other: "bg-gray-800 text-gray-300", -}; +// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow). +// `severityBadge()` already returns null for the legacy "unknown" value. -const SEVERITY_COLORS: Record = { - major: "bg-red-950 text-red-400", - moderate: "bg-orange-950 text-orange-400", - minor: "bg-gray-800 text-gray-400", -}; +type SeverityFilter = "all" | "minor" | "moderate" | "major"; +const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [ + { key: "all", label: "All" }, + { key: "minor", label: "Minor+" }, + { key: "moderate", label: "Moderate+" }, + { key: "major", label: "Major only" }, +]; +const FILTER_THRESHOLD: Record = { all: -1, minor: 1, moderate: 2, major: 3 }; -function severityBadge(severity: string | null | undefined) { - if (!severity || severity === "unknown") return null; - const cls = SEVERITY_COLORS[severity] ?? "bg-gray-800 text-gray-400"; - return ( - - {severity} - - ); -} - -function typeBadge(type: string | null) { - const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other; - return ( - - {type ?? "other"} - - ); -} +type SortMode = "recent" | "severity"; function fmtTime(iso: string) { try { return new Date(iso).toLocaleString(); } catch { return iso; } } +// --------------------------------------------------------------------------- +// Rows / cards +// --------------------------------------------------------------------------- + function IncidentRow({ incident, isAdmin, onResolve }: { incident: IncidentRecord; isAdmin: boolean; @@ -53,19 +46,13 @@ function IncidentRow({ incident, isAdmin, onResolve }: { return ( router.push(`/incidents/${incident.incident_id}`)} > - {typeBadge(incident.type)} + {incident.title ?? "—"} - - {incident.status} - + {incident.status} {severityBadge(incident.severity)} {incident.call_ids.length} @@ -73,26 +60,98 @@ function IncidentRow({ incident, isAdmin, onResolve }: { {fmtTime(incident.updated_at)} {isAdmin && incident.status === "active" && ( - + )} ); } -function CreateModal({ onClose, onCreate }: { - onClose: () => void; - onCreate: (body: object) => Promise; +function IncidentCards({ incidents, isAdmin, onResolve }: { + incidents: IncidentRecord[]; + isAdmin: boolean; + onResolve: (id: string) => void; }) { - const [title, setTitle] = useState(""); - const [type, setType] = useState("other"); + const router = useRouter(); + return ( +
+ {incidents.map((inc) => ( + router.push(`/incidents/${inc.incident_id}`)} + > +
+
+ + {inc.status} +
+ {isAdmin && inc.status === "active" && ( + + )} +
+

{inc.title ?? "—"}

+
+ {severityBadge(inc.severity)} +

+ {fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""} +

+
+
+ ))} +
+ ); +} + +function IncidentTable({ incidents, isAdmin, onResolve }: { + incidents: IncidentRecord[]; + isAdmin: boolean; + onResolve: (id: string) => void; +}) { + return ( + <> +
+ +
+
+ + + + + + + + + + + + + + + {incidents.map((inc) => ( + + ))} + +
TypeTitleStatusSeverityCallsStartedUpdated
+
+ + ); +} + +function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise }) { + const [title, setTitle] = useState(""); + const [type, setType] = useState("other"); const [summary, setSummary] = useState(""); - const [saving, setSaving] = useState(false); + const [saving, setSaving] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: { } return ( -
-
+
+

Create Incident

@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: { />
- - + +
); } -function IncidentCards({ incidents, isAdmin, onResolve }: { - incidents: IncidentRecord[]; - isAdmin: boolean; - onResolve: (id: string) => void; -}) { - const router = useRouter(); - return ( -
- {incidents.map((inc) => ( -
router.push(`/incidents/${inc.incident_id}`)} - > -
-
- {typeBadge(inc.type)} - {inc.status} -
- {isAdmin && inc.status === "active" && ( - - )} -
-

{inc.title ?? "—"}

-
- {severityBadge(inc.severity)} -

- {fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""} -

-
-
- ))} -
- ); -} - -function IncidentTable({ incidents, isAdmin, onResolve }: { - incidents: IncidentRecord[]; - isAdmin: boolean; - onResolve: (id: string) => void; -}) { - return ( - <> - {/* Mobile card view */} -
- -
- - {/* Desktop table view */} -
- - - - - - - - - - - - - - - {incidents.map((inc) => ( - - ))} - -
TypeTitleStatusSeverityCallsStartedUpdated
-
- - ); -} +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- export default function IncidentsPage() { - const { isAdmin } = useAuth(); - const { incidents, loading } = useIncidents(); + const { isAdmin } = useAuth(); + const { incidents, loading } = useIncidents(); const [showCreate, setShowCreate] = useState(false); + const [severityFilter, setSeverityFilter] = useState("all"); + const [sortMode, setSortMode] = useState("recent"); - const active = incidents.filter((i) => i.status === "active"); - const resolved = incidents.filter((i) => i.status === "resolved"); + const filtered = useMemo(() => { + const threshold = FILTER_THRESHOLD[severityFilter]; + const list = incidents.filter((i) => severityRank(i.severity) >= threshold); + if (sortMode === "severity") { + return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)); + } + return list; // useIncidents() already orders by started_at desc + }, [incidents, severityFilter, sortMode]); + + const active = filtered.filter((i) => i.status === "active"); + const resolved = filtered.filter((i) => i.status === "resolved"); + const hiddenCount = incidents.length - filtered.length; async function handleResolve(id: string) { try { await c2api.updateIncident(id, { status: "resolved" }); } @@ -253,30 +232,53 @@ export default function IncidentsPage() { } return ( -
-
-
-

Incidents

- {active.length > 0 && ( - - {active.length} active - - )} +
+ 0 && {active.length} active} + action={isAdmin && } + /> + + {/* Severity filter + sort — severity is a filter dimension, not decoration */} +
+
+ {SEVERITY_FILTERS.map(({ key, label }) => ( + + ))}
- {isAdmin && ( -
{loading ? ( -

Loading…

+
+ +
) : ( <> + {hiddenCount > 0 && ( +

+ {hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter. +

+ )} + {active.length > 0 && (

Active

@@ -291,8 +293,20 @@ export default function IncidentsPage() {
)} - {incidents.length === 0 && ( -

No incidents recorded yet.

+ {filtered.length === 0 && ( + 0 && severityFilter !== "all" ? ( + + ) : undefined + } + /> )} )} diff --git a/drb-frontend/app/layout.tsx b/drb-frontend/app/layout.tsx index 355c372..d09208c 100644 --- a/drb-frontend/app/layout.tsx +++ b/drb-frontend/app/layout.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; -import { Nav } from "@/components/Nav"; import { AuthProvider } from "@/components/AuthProvider"; import { ThemeProvider } from "@/components/ThemeProvider"; +import { ChromeSwitcher } from "@/components/ChromeSwitcher"; import "./globals.css"; export const metadata: Metadata = { - title: "DRB Portal", - description: "Distributed Radio Bot — Control & Monitoring", + title: "DRB — Public-Safety Radio Intelligence", + description: "Live incident awareness from field SDR nodes — transcribed, correlated, and mapped in real time.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -19,8 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) -