From e6b15811393fade3197b5381575d6350009cebd6 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 4 Oct 2026 17:58:49 -0400 Subject: [PATCH] Live wall: pinned/docked cameras stop when their provider is switched off; admin-only empty-state CTA - LiveView: localStorage pins carry image URLs, so they rendered (without credit) after an admin disabled the provider. Pins now show only while their provider is serving (server-26#198 licence gating). - MapView: pinned/docked map players drop out once their provider leaves the serving list; credits are kept from the last answer so docked players keep attribution while the overlay is hidden. - WallPanels: the "no camera provider enabled" link to /admin was a dead CTA for non-admins (bounced to /). Link shown to admins only, deep-linked to /admin#cameras; the admin page now honours a tab hash. Co-Authored-By: Claude Opus 5.5 --- drb-frontend/app/admin/page.tsx | 5 +++++ drb-frontend/components/LiveView.tsx | 10 +++++++--- drb-frontend/components/MapView.tsx | 12 ++++++++++-- drb-frontend/components/live/WallPanels.tsx | 11 ++++++++--- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index 682a927..6a744c9 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -1285,6 +1285,11 @@ export default function AdminPage() { const { user, isAdmin, loading: authLoading } = useAuth(); const router = useRouter(); const [tab, setTab] = useState("features"); + // Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state. + useEffect(() => { + const h = window.location.hash.slice(1); + if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab); + }, []); // Wait for the claims to resolve before deciding. isAdmin is false for the // first render of every cold load (typed URL, hard refresh, bookmark) while diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx index f6b6771..d0cc2c2 100644 --- a/drb-frontend/components/LiveView.tsx +++ b/drb-frontend/components/LiveView.tsx @@ -145,6 +145,10 @@ export function LiveView() { }, [pointsById, nodes]); const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox); const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]); + // Pins live in this browser and carry their image URLs, so a pin must not + // outlive its provider: once an admin switches a provider off (licence + // gating, server-26#198) its pinned cameras stop rendering until it is back. + const servingPinned = useMemo(() => pinned.filter((c) => feeds.has(c.provider)), [pinned, feeds]); const nearById = useMemo(() => { const m = new Map(); pointsById.forEach((pts, id) => m.set(id, nearestCameras(pts, cams.cameras))); @@ -158,19 +162,19 @@ export function LiveView() { const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : []; const near = selected ? nearById.get(selected.incident_id) ?? [] : []; const drillCam: Camera | null = link.cam - ? cams.cameras.find((c) => c.id === link.cam) ?? pinned.find((c) => c.id === link.cam) ?? null + ? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null : null; // Region wall: pinned first, then the nearest camera to each major/moderate incident. const wallCameras = useMemo(() => { - const out: { cam: Camera; km?: number }[] = pinned.map((cam) => ({ cam })); + const out: { cam: Camera; km?: number }[] = servingPinned.map((cam) => ({ cam })); for (const inc of sorted) { if (severityRank(inc.severity) < 2) continue; const first = nearById.get(inc.incident_id)?.[0]; if (first && !out.some((o) => o.cam.id === first.cam.id)) out.push({ cam: first.cam, km: first.km }); } return out.slice(0, 6); - }, [pinned, sorted, nearById]); + }, [servingPinned, sorted, nearById]); // ── Navigation ──────────────────────────────────────────────────────────── const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]); diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 5db829c..6efa562 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -564,8 +564,16 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void } const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS); const zoom = useMapZoom(map); const { data, error, loaded } = useMapCameras(map, shown); - const [open, setOpen] = useState([]); - const feeds = useMemo(() => creditsByProvider(data.providers), [data.providers]); + const [openAll, setOpen] = useState([]); + // Credits from the last answer received while the overlay was on. Hiding the + // overlay empties `data`, but docked players stay up and still need their + // attribution. A pinned/docked camera whose provider an admin has since + // switched off (licence gating, server-26#198) drops out on the next answer. + const [feeds, setFeeds] = useState>(() => new Map()); + useEffect(() => { + if (shown && loaded) setFeeds(creditsByProvider(data.providers)); + }, [shown, loaded, data.providers]); + const open = useMemo(() => openAll.filter((o) => feeds.has(o.cam.provider)), [openAll, feeds]); const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]); const cells = useMemo( diff --git a/drb-frontend/components/live/WallPanels.tsx b/drb-frontend/components/live/WallPanels.tsx index 6ade70d..584a5af 100644 --- a/drb-frontend/components/live/WallPanels.tsx +++ b/drb-frontend/components/live/WallPanels.tsx @@ -10,6 +10,7 @@ import type { AircraftTrack, CallRecord, Camera, CameraCredit, CameraProviderFeed, IncidentRecord, Ny511Event, NodeRecord, VesselTrack, } from "@/lib/types"; import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL } from "@/lib/severity"; +import { useAuth } from "@/components/AuthProvider"; import { CameraFeed } from "@/components/CameraFeed"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; @@ -43,11 +44,15 @@ export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: Came } function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm: number }) { + // Only admins can open /admin (everyone else is bounced to /), so only they get the link. + const { isAdmin } = useAuth(); return (

- {servingCount === 0 - ? <>No camera provider is enabled. An admin turns them on under Admin → Camera providers. - : `No public camera within ${radiusKm} km.`} + {servingCount !== 0 + ? `No public camera within ${radiusKm} km.` + : isAdmin + ? <>No camera provider is enabled. Turn one on under Admin → Camera providers. + : "No camera provider is enabled. A platform admin can turn them on."}

); }