diff --git a/drb-frontend/app/globals.css b/drb-frontend/app/globals.css index e5fb56a..e9ae644 100644 --- a/drb-frontend/app/globals.css +++ b/drb-frontend/app/globals.css @@ -96,6 +96,15 @@ html:not(.dark) .text-indigo-300 { color: #4338ca !important; } html:not(.dark) .text-indigo-400 { color: #6366f1 !important; } html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; } +/* ── Pulsing ring for recording nodes ────────────────────────────────────── */ +@keyframes pulse-ring { + 0% { transform: scale(1); opacity: 0.85; } + 100% { transform: scale(2.4); opacity: 0; } +} +.node-pulse-ring { + animation: pulse-ring 1.8s ease-out infinite; +} + /* ── Form inputs ─────────────────────────────────────────────────────────── */ html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) select, diff --git a/drb-frontend/app/map/page.tsx b/drb-frontend/app/map/page.tsx index 323f225..ce25386 100644 --- a/drb-frontend/app/map/page.tsx +++ b/drb-frontend/app/map/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { useNodes } from "@/lib/useNodes"; @@ -45,12 +46,55 @@ function IncidentCard({ incident }: { incident: IncidentRecord }) { export default function MapPage() { const { nodes, loading } = useNodes(); - const activeCalls = useActiveCalls(); - const incidents = useActiveIncidents(); + const activeCalls = useActiveCalls(); + const incidents = useActiveIncidents(); + const [kiosk, setKiosk] = useState(false); + const [lastUpdated, setLastUpdated] = useState(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 ( +
+ + +
+ ); + } return (
-

Map

+
+

Map

+ +
{loading ? (
@@ -58,11 +102,15 @@ export default function MapPage() {
) : (
- +
)} - {/* Active incidents — shown even without geocoded location */} {incidents.length > 0 && (

diff --git a/drb-frontend/app/systems/page.tsx b/drb-frontend/app/systems/page.tsx index d3f7add..56e9476 100644 --- a/drb-frontend/app/systems/page.tsx +++ b/drb-frontend/app/systems/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useSystems } from "@/lib/useSystems"; import { c2api } from "@/lib/c2api"; import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types"; -// ── P25 structured config types ────────────────────────────────────────────── +// ── P25 structured config types ─────────────────────────────────────────────── interface TalkgroupEntry { id: string; @@ -72,6 +72,248 @@ function p25ConfigToRecord(p: P25Config): Record { }; } +// ── RadioReference parser types ─────────────────────────────────────────────── + +interface RRTalkgroup { + dec: number; + alphaTag: string; + description: string; + tag: string; +} + +interface RRCategory { + name: string; + talkgroups: RRTalkgroup[]; +} + +interface RRSystem { + name: string; + location: string; + sysIds: string; + systemType: string; + categories: RRCategory[]; +} + +function mapRRTag(rrTag: string): string { + const t = rrTag.toLowerCase(); + if (t.includes("fire")) return "fire"; + if (t.includes("law") || t.includes("police")) return "police"; + if (t.includes("ems") || t.includes("emergency medical")) return "ems"; + if (t.includes("transport") || t.includes("transit")) return "transit"; + if (t.includes("public works")) return "public works"; + return "other"; +} + +function parseRadioReference(html: string): RRSystem | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + + // Validate: RadioReference system pages have rrlblue header cells + if (!doc.querySelector(".rrlblue")) return null; + + // System info table (first table with rrlblue headers) + const infoMap: Record = {}; + const infoTable = doc.querySelector("table.table-sm.table-bordered"); + if (infoTable) { + infoTable.querySelectorAll("tr").forEach((row) => { + const th = row.querySelector("th.rrlblue"); + const td = row.querySelector("td"); + if (th && td) infoMap[th.textContent?.trim() ?? ""] = td.textContent?.trim() ?? ""; + }); + } + + const name = infoMap["System Name"] ?? doc.title ?? "Unknown System"; + const location = infoMap["Location"] ?? ""; + const sysIds = infoMap["System IDs"] ?? ""; + const systemType = infoMap["System Type"] ?? ""; + + // Talkgroup tables — find all with class rrdbTable or datatable-lite + // For each, find the nearest preceding h5 to use as category name + const tgTables = Array.from( + doc.querySelectorAll("table.rrdbTable, table.datatable-lite") + ) as HTMLTableElement[]; + + const allH5s = Array.from(doc.querySelectorAll("h5")) as HTMLElement[]; + + function categoryForTable(table: HTMLTableElement): string { + // Find the last h5 that appears before this table in document order + let best: HTMLElement | null = null; + for (const h5 of allH5s) { + const pos = h5.compareDocumentPosition(table); + if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = h5; + } + if (!best) return "Uncategorized"; + const clone = best.cloneNode(true) as HTMLElement; + clone.querySelectorAll("div, button, span.badge").forEach((el) => el.remove()); + return clone.textContent?.trim() || "Uncategorized"; + } + + const categories: RRCategory[] = []; + + for (const table of tgTables) { + // Confirm it has the expected talkgroup columns (DEC, HEX, Mode, Alpha Tag, …) + const headers = Array.from(table.querySelectorAll("thead th")).map((th) => + th.textContent?.trim().toLowerCase() + ); + if (!headers.includes("dec") && !headers.includes("hex")) continue; + + const catName = categoryForTable(table); + const talkgroups: RRTalkgroup[] = []; + + table.querySelectorAll("tbody tr").forEach((row) => { + const cells = Array.from(row.querySelectorAll("td")); + if (cells.length < 6) return; + + // DEC cell may wrap in a Broadcastify link + const decText = + cells[0].querySelector("a")?.textContent?.trim() ?? + cells[0].textContent?.trim() ?? + ""; + const dec = parseInt(decText.replace(/\D/g, ""), 10); + if (isNaN(dec)) return; + + const alphaTag = cells[3].textContent?.trim() ?? ""; + const description = cells[4].textContent?.trim() ?? ""; + const tag = cells[5].textContent?.trim() ?? ""; + + talkgroups.push({ dec, alphaTag, description, tag }); + }); + + if (talkgroups.length > 0) { + // Merge into an existing category with same name if present + const existing = categories.find((c) => c.name === catName); + if (existing) { + existing.talkgroups.push(...talkgroups); + } else { + categories.push({ name: catName, talkgroups }); + } + } + } + + if (categories.length === 0) return null; + + return { name, location, sysIds, systemType, categories }; +} + +// ── RadioReference import modal ─────────────────────────────────────────────── + +function RRImportModal({ + system, + onImport, + onCancel, +}: { + system: RRSystem; + onImport: (tgs: TalkgroupEntry[]) => void; + onCancel: () => void; +}) { + const [selected, setSelected] = useState>( + () => new Set(system.categories.map((c) => c.name)) + ); + + function toggle(name: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); else next.add(name); + return next; + }); + } + + function handleImport() { + const tgs: TalkgroupEntry[] = []; + for (const cat of system.categories) { + if (!selected.has(cat.name)) continue; + for (const tg of cat.talkgroups) { + tgs.push({ + id: String(tg.dec), + name: tg.description || tg.alphaTag, + tag: mapRRTag(tg.tag), + }); + } + } + onImport(tgs); + } + + const total = system.categories.reduce((s, c) => s + c.talkgroups.length, 0); + const selectedCount = system.categories + .filter((c) => selected.has(c.name)) + .reduce((s, c) => s + c.talkgroups.length, 0); + + return ( +
+
+ {/* Header */} +
+

{system.name}

+

+ {system.systemType}{system.location ? ` · ${system.location}` : ""} +

+ {system.sysIds && ( +

System IDs: {system.sysIds}

+ )} +

+ {system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups +

+
+ + {/* Category list */} +
+
+

Talkgroup Categories

+
+ + +
+
+ {system.categories.map((cat) => ( + + ))} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + // ── Talkgroup table editor ──────────────────────────────────────────────────── function TalkgroupEditor({ @@ -83,6 +325,9 @@ function TalkgroupEditor({ }) { const [showPaste, setShowPaste] = useState(false); const [pasteText, setPasteText] = useState(""); + const [rrSystem, setRrSystem] = useState(null); + const [rrError, setRrError] = useState(null); + const rrInputRef = useRef(null); function addRow() { onChange([...talkgroups, { id: "", name: "", tag: "other" }]); @@ -115,13 +360,61 @@ function TalkgroupEditor({ setShowPaste(false); } + async function handleRRFile(file: File) { + setRrError(null); + const html = await file.text(); + const parsed = parseRadioReference(html); + if (!parsed) { + setRrError( + "This doesn't look like a RadioReference trunked system page. " + + "Download the HTML from a system page on radioreference.com and try again." + ); + return; + } + setRrSystem(parsed); + } + + function handleRRImport(newTgs: TalkgroupEntry[]) { + onChange([...talkgroups, ...newTgs]); + setRrSystem(null); + setRrError(null); + } + return (
+ {rrSystem && ( + setRrSystem(null)} + /> + )} +
+ {/* RadioReference file import */} + + { + const f = e.target.files?.[0]; + if (f) handleRRFile(f); + e.target.value = ""; + }} + /> +
+ {rrError && ( +

+ {rrError} +

+ )} + {showPaste && (

- Paste rows from RadioReference — tab- or comma-separated: ID, Name, Tag + Paste rows from RadioReference — tab- or comma-separated:{" "} + ID, Name, Tag
Tags: fire · police · ems · transit · public works · other