Massive update
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
@@ -12,40 +15,102 @@ function duration(started: string, ended: string | null): string {
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
const TAG_COLORS: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
export function CallRow({ call, systemName }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isActive = call.status === "active";
|
||||
const hasDetails = call.transcript || (call.tags && call.tags.length > 0) || call.incident_id;
|
||||
|
||||
return (
|
||||
<tr className="border-b border-gray-800 hover:bg-gray-900/50 font-mono text-sm">
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">
|
||||
{new Date(call.started_at).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-300">
|
||||
{call.talkgroup_name || call.talkgroup_id || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
|
||||
<td className="px-4 py-2">
|
||||
{isActive ? (
|
||||
<span className="text-orange-400 animate-pulse">● live</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{call.audio_url ? (
|
||||
<a
|
||||
href={call.audio_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 text-xs"
|
||||
>
|
||||
audio
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-700 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<>
|
||||
<tr
|
||||
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`}
|
||||
onClick={() => hasDetails && setExpanded((v) => !v)}
|
||||
>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">
|
||||
{new Date(call.started_at).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-300">
|
||||
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
||||
{call.tags && call.tags.length > 0 && (
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}>
|
||||
{call.tags[0]}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
|
||||
<td className="px-4 py-2">
|
||||
{isActive ? (
|
||||
<span className="text-orange-400 animate-pulse">● live</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{call.audio_url ? (
|
||||
<a
|
||||
href={call.audio_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-blue-400 hover:text-blue-300 text-xs"
|
||||
>
|
||||
audio
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-700 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 text-xs">
|
||||
{hasDetails && (expanded ? "▲" : "▼")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{expanded && hasDetails && (
|
||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||
{/* Tags */}
|
||||
{call.tags && call.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{call.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incident link */}
|
||||
{call.incident_id && (
|
||||
<p className="text-xs font-mono text-indigo-400">
|
||||
Incident:{" "}
|
||||
<a href="/incidents" className="underline hover:text-indigo-300">
|
||||
{call.incident_id.slice(0, 8)}…
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Transcript */}
|
||||
{call.transcript ? (
|
||||
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
||||
{call.transcript}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { NodeRecord, CallRecord } from "@/lib/types";
|
||||
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
||||
|
||||
// Fix Leaflet default icon paths broken by webpack
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
@@ -25,16 +25,45 @@ const nodeIcon = (status: string) =>
|
||||
iconAnchor: [7, 7],
|
||||
});
|
||||
|
||||
const INCIDENT_COLORS: Record<string, string> = {
|
||||
fire: "#ef4444",
|
||||
police: "#3b82f6",
|
||||
ems: "#eab308",
|
||||
accident: "#f97316",
|
||||
other: "#6b7280",
|
||||
};
|
||||
|
||||
const incidentIcon = (type: string | null) => {
|
||||
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="
|
||||
width:16px;height:16px;border-radius:3px;
|
||||
background:${color};border:2px solid #111827;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:9px;color:#111827;font-weight:bold;line-height:1;
|
||||
">!</div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
});
|
||||
};
|
||||
|
||||
interface Props {
|
||||
nodes: NodeRecord[];
|
||||
activeCalls: CallRecord[];
|
||||
incidents?: IncidentRecord[];
|
||||
}
|
||||
|
||||
export default function MapView({ nodes, activeCalls }: Props) {
|
||||
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
const activeByNode = Object.fromEntries(
|
||||
activeCalls.map((c) => [c.node_id, c])
|
||||
);
|
||||
|
||||
// Only show incidents that have coordinates
|
||||
const mappableIncidents = incidents.filter(
|
||||
(i) => i.location && i.location.lat != null && i.location.lng != null
|
||||
);
|
||||
|
||||
const center: [number, number] =
|
||||
nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : [39.5, -98.35];
|
||||
|
||||
@@ -49,6 +78,8 @@ export default function MapView({ nodes, activeCalls }: Props) {
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
|
||||
{/* Node markers */}
|
||||
{nodes.map((node) => (
|
||||
<Marker
|
||||
key={node.node_id}
|
||||
@@ -70,6 +101,27 @@ export default function MapView({ nodes, activeCalls }: Props) {
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Incident markers */}
|
||||
{mappableIncidents.map((inc) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
position={[inc.location!.lat, inc.location!.lng]}
|
||||
icon={incidentIcon(inc.type)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-bold">{inc.title ?? "Incident"}</p>
|
||||
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] }}>
|
||||
{inc.type ?? "other"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
||||
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
|
||||
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/nodes", label: "Nodes" },
|
||||
{ href: "/systems", label: "Systems" },
|
||||
{ href: "/calls", label: "Calls" },
|
||||
{ href: "/map", label: "Map" },
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/nodes", label: "Nodes" },
|
||||
{ href: "/systems", label: "Systems" },
|
||||
{ href: "/calls", label: "Calls" },
|
||||
{ href: "/incidents", label: "Incidents" },
|
||||
{ href: "/map", label: "Map" },
|
||||
{ href: "/alerts", label: "Alerts" },
|
||||
];
|
||||
|
||||
const adminLinks = [
|
||||
@@ -21,17 +24,18 @@ export function Nav() {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const { nodes: pending } = useUnconfiguredNodes();
|
||||
const unackedAlerts = useUnacknowledgedAlerts();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6">
|
||||
<span className="font-mono font-bold text-white tracking-tight mr-4">DRB</span>
|
||||
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
|
||||
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
|
||||
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`text-sm font-mono transition-colors ${
|
||||
className={`text-sm font-mono transition-colors shrink-0 ${
|
||||
pathname.startsWith(href)
|
||||
? "text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
@@ -43,9 +47,14 @@ export function Nav() {
|
||||
{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">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto shrink-0">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user