UI Updates
app/map/page.tsx
Removed IncidentCard component and the incidents grid below the map — the on-map sidebar inside MapView is the single display
Moved kiosk exit button from top-3 left-3 (overlapping zoom controls) to bottom-[5.5rem] left-3
components/MapView.tsx
Fixed popup "View incident →" link — adds stopPropagation() + window.location.href to prevent Leaflet intercepting the click
Added "View details →" link on each sidebar incident card so you can navigate from the map panel without opening a popup
Added "News Alerts" overlay layer (placeholder, ready for RSS/feed integration)
lib/types.ts
Added preferred_token_id?: string | null to SystemRecord
lib/c2api.ts
Added setPreferredToken(tokenId, systemId) calling PUT /tokens/{tokenId}/prefer/{systemId} (backend already existed)
app/systems/page.tsx
Added PreferredTokenPanel component — loads the token pool lazily on expand, shows radio buttons to set/clear the preferred token, displayed on each system card above the AI flags panel
This commit is contained in:
@@ -2,48 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
|
||||||
fire: "border-red-800 bg-red-950 text-red-300",
|
|
||||||
police: "border-blue-800 bg-blue-950 text-blue-300",
|
|
||||||
ems: "border-yellow-800 bg-yellow-950 text-yellow-300",
|
|
||||||
accident: "border-orange-800 bg-orange-950 text-orange-300",
|
|
||||||
other: "border-gray-700 bg-gray-900 text-gray-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
function IncidentCard({ incident }: { incident: IncidentRecord }) {
|
|
||||||
const cls = TYPE_COLORS[incident.type ?? "other"] ?? TYPE_COLORS.other;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/incidents/${incident.incident_id}`}
|
|
||||||
className={`block border rounded-lg p-3 hover:brightness-110 transition-all ${cls}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
|
||||||
<span className="text-xs font-mono font-semibold uppercase tracking-wide">
|
|
||||||
{incident.type ?? "other"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs opacity-60 font-mono">
|
|
||||||
{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-bold leading-tight">{incident.title ?? "Incident"}</p>
|
|
||||||
{incident.location && (
|
|
||||||
<p className="text-xs opacity-70 mt-1 font-mono truncate">{incident.location}</p>
|
|
||||||
)}
|
|
||||||
{!incident.location_coords && (
|
|
||||||
<p className="text-xs opacity-40 mt-1 font-mono italic">location not geocoded yet</p>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
@@ -69,7 +33,7 @@ export default function MapPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setKiosk(false)}
|
onClick={() => setKiosk(false)}
|
||||||
title="Exit fullscreen"
|
title="Exit fullscreen"
|
||||||
className="absolute top-3 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"
|
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">
|
<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"/>
|
<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"/>
|
||||||
@@ -111,18 +75,6 @@ export default function MapPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{incidents.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
|
||||||
Active Incidents ({incidents.length})
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
||||||
{incidents.map((inc) => (
|
|
||||||
<IncidentCard key={inc.incident_id} incident={inc} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -739,6 +739,123 @@ function SystemForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Preferred bot token panel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TokenOption {
|
||||||
|
token_id: string;
|
||||||
|
name: string;
|
||||||
|
in_use: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; initialTokenId?: string | null }) {
|
||||||
|
const [preferredId, setPreferredId] = useState<string | null>(initialTokenId ?? null);
|
||||||
|
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (tokens !== null) return;
|
||||||
|
try {
|
||||||
|
const data = await c2api.getTokens();
|
||||||
|
setTokens(data as TokenOption[]);
|
||||||
|
} catch {
|
||||||
|
setTokens([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!open) load();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSet(tokenId: string) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await c2api.setPreferredToken(tokenId, systemId);
|
||||||
|
setPreferredId(tokenId);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClear() {
|
||||||
|
if (!preferredId) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await c2api.setPreferredToken(preferredId, "_none");
|
||||||
|
setPreferredId(null);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
|
<span>
|
||||||
|
Preferred Bot Token
|
||||||
|
{preferredId && <span className="ml-1.5 text-indigo-400">● set</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||||
|
{tokens === null ? (
|
||||||
|
<p className="text-gray-600 italic">Loading tokens…</p>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<p className="text-gray-600 italic">No tokens in pool.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
When a node on this system joins a voice channel, this token is tried first.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<label key={t.token_id} className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`preferred-token-${systemId}`}
|
||||||
|
checked={preferredId === t.token_id}
|
||||||
|
onChange={() => handleSet(t.token_id)}
|
||||||
|
disabled={saving}
|
||||||
|
className="accent-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
|
||||||
|
{t.name}
|
||||||
|
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preferredId && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Clear preference (use any free token)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!preferredId && (
|
||||||
|
<p className="text-gray-700">No preference — any free token will be used.</p>
|
||||||
|
)}
|
||||||
|
{currentToken && (
|
||||||
|
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Per-system AI flags panel ─────────────────────────────────────────────────
|
// ── Per-system AI flags panel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SystemAiFlags {
|
interface SystemAiFlags {
|
||||||
@@ -1157,6 +1274,7 @@ export default function SystemsPage() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
|
||||||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||||
<VocabularyPanel systemId={s.system_id} />
|
<VocabularyPanel systemId={s.system_id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ function FanIncidentLayer({
|
|||||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||||
<a
|
<a
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||||||
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
||||||
>
|
>
|
||||||
View incident →
|
View incident →
|
||||||
@@ -451,6 +452,11 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
/>
|
/>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
|
{/* Overlay: News / RSS alerts — placeholder for future integration */}
|
||||||
|
<LayersControl.Overlay name="News Alerts">
|
||||||
|
<FeatureGroup />
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: ADS-B — placeholder for future integration */}
|
{/* Overlay: ADS-B — placeholder for future integration */}
|
||||||
<LayersControl.Overlay name="ADS-B">
|
<LayersControl.Overlay name="ADS-B">
|
||||||
<FeatureGroup />
|
<FeatureGroup />
|
||||||
@@ -513,40 +519,50 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||||
const unitCount = inc.units?.length ?? 0;
|
const unitCount = inc.units?.length ?? 0;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
onClick={() => handleIncidentSelect(inc)}
|
|
||||||
className="w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all"
|
className="w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all"
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: color + "55" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<button
|
||||||
<span
|
onClick={() => handleIncidentSelect(inc)}
|
||||||
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
className="w-full text-left"
|
||||||
style={{ background: color }}
|
>
|
||||||
/>
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<span
|
<span
|
||||||
className="uppercase tracking-wide font-semibold text-[10px]"
|
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
||||||
style={{ color }}
|
style={{ background: color }}
|
||||||
>
|
/>
|
||||||
{inc.type ?? "other"}
|
<span
|
||||||
</span>
|
className="uppercase tracking-wide font-semibold text-[10px]"
|
||||||
</div>
|
style={{ color }}
|
||||||
<p className="text-white font-semibold leading-snug truncate">
|
>
|
||||||
{inc.title ?? "Incident"}
|
{inc.type ?? "other"}
|
||||||
</p>
|
</span>
|
||||||
{inc.location && (
|
</div>
|
||||||
<p className="text-gray-500 truncate mt-0.5">{inc.location}</p>
|
<p className="text-white font-semibold leading-snug truncate">
|
||||||
)}
|
{inc.title ?? "Incident"}
|
||||||
<div className="flex items-center justify-between mt-0.5">
|
</p>
|
||||||
{age && <span className="text-gray-600">{age}</span>}
|
{inc.location && (
|
||||||
{unitCount > 0 && (
|
<p className="text-gray-500 truncate mt-0.5">{inc.location}</p>
|
||||||
<span className="text-gray-600">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
{!inc.location_coords && (
|
{age && <span className="text-gray-600">{age}</span>}
|
||||||
<p className="text-gray-700 italic mt-0.5">no coords</p>
|
{unitCount > 0 && (
|
||||||
)}
|
<span className="text-gray-600">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
||||||
</button>
|
)}
|
||||||
|
</div>
|
||||||
|
{!inc.location_coords && (
|
||||||
|
<p className="text-gray-700 italic mt-0.5">no coords</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/incidents/${inc.incident_id}`}
|
||||||
|
className="block text-[10px] text-blue-700 hover:text-blue-500 mt-1 transition-colors"
|
||||||
|
>
|
||||||
|
View details →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ export const c2api = {
|
|||||||
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
||||||
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
||||||
|
|
||||||
|
// Preferred bot token per system
|
||||||
|
setPreferredToken: (tokenId: string, systemId: string) =>
|
||||||
|
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
||||||
|
|
||||||
// Per-system AI flag overrides
|
// Per-system AI flag overrides
|
||||||
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
||||||
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface SystemRecord {
|
|||||||
vocabulary_pending?: VocabularyPendingTerm[];
|
vocabulary_pending?: VocabularyPendingTerm[];
|
||||||
vocabulary_bootstrapped?: boolean;
|
vocabulary_bootstrapped?: boolean;
|
||||||
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
||||||
|
preferred_token_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptSegment {
|
export interface TranscriptSegment {
|
||||||
|
|||||||
Reference in New Issue
Block a user