UI Updates
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, Marker, Popup, LayersControl, FeatureGroup } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
||||
|
||||
@@ -59,7 +59,6 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
activeCalls.map((c) => [c.node_id, c])
|
||||
);
|
||||
|
||||
// Only show incidents that have been geocoded (location_coords set by the server).
|
||||
const plottedIncidents = incidents.flatMap((inc) =>
|
||||
inc.location_coords
|
||||
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
||||
@@ -81,64 +80,104 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
: 4;
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
<div className="relative w-full h-full">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
>
|
||||
<LayersControl position="topright">
|
||||
{/* Base layers */}
|
||||
<LayersControl.BaseLayer checked name="Dark">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Light">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Streets">
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
|
||||
{/* Node markers */}
|
||||
{nodes.map((node) => (
|
||||
<Marker
|
||||
key={node.node_id}
|
||||
position={[node.lat, node.lon]}
|
||||
icon={nodeIcon(node.status)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-bold">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs mt-1 capitalize">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
{activeByNode[node.node_id].talkgroup_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
{/* Overlay: Nodes */}
|
||||
<LayersControl.Overlay checked name="Nodes">
|
||||
<FeatureGroup>
|
||||
{nodes.map((node) => (
|
||||
<Marker
|
||||
key={node.node_id}
|
||||
position={[node.lat, node.lon]}
|
||||
icon={nodeIcon(node.status)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-bold">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs mt-1 capitalize">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
{activeByNode[node.node_id].talkgroup_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Incident markers — positioned at the node covering the incident's system */}
|
||||
{plottedIncidents.map(({ inc, pos }) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
position={pos}
|
||||
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"] ?? INCIDENT_COLORS.other }}>
|
||||
{inc.type ?? "other"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
||||
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</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>}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
|
||||
View incident →
|
||||
</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
{/* Overlay: Active Incidents */}
|
||||
<LayersControl.Overlay checked name="Active Incidents">
|
||||
<FeatureGroup>
|
||||
{plottedIncidents.map(({ inc, pos }) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
position={pos}
|
||||
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"] ?? INCIDENT_COLORS.other }}>
|
||||
{inc.type ?? "other"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
||||
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</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>}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
|
||||
View incident →
|
||||
</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
</LayersControl>
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend overlay — inside the map wrapper, above tiles */}
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
|
||||
<div className="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
||||
<div className="flex items-center gap-2"><span className="text-indigo-400">●</span> Unconfigured</div>
|
||||
<div className="flex items-center gap-2"><span className="text-gray-500">●</span> Offline</div>
|
||||
<div className="border-t border-gray-800 my-0.5" />
|
||||
<div className="flex items-center gap-2"><span className="text-red-500">■</span> Fire</div>
|
||||
<div className="flex items-center gap-2"><span className="text-blue-500">■</span> Police</div>
|
||||
<div className="flex items-center gap-2"><span className="text-yellow-500">■</span> EMS</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+131
-32
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
@@ -21,48 +23,145 @@ const adminLinks = [
|
||||
{ href: "/admin", label: "Admin" },
|
||||
];
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Nav() {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const { nodes: pending } = useUnconfiguredNodes();
|
||||
const unackedAlerts = useUnacknowledgedAlerts();
|
||||
const { theme, toggle } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const allLinks = [...links, ...(isAdmin ? adminLinks : [])];
|
||||
|
||||
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 (
|
||||
<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 shrink-0 ${
|
||||
pathname.startsWith(href)
|
||||
? "text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
{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 shrink-0">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/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>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
|
||||
{allLinks.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">
|
||||
{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>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
|
||||
{/* Sign out (desktop) */}
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
{/* Hamburger (mobile) */}
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 }) => (
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
{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">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
|
||||
theme: "dark",
|
||||
toggle: () => {},
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("drb-theme") as Theme | null;
|
||||
if (saved === "light") setTheme("light");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
localStorage.setItem("drb-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle: () => setTheme((t) => (t === "dark" ? "light" : "dark")) }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user