Live ops wall + pluggable camera providers with licence gating (server-26#198) #208
@@ -1285,6 +1285,11 @@ export default function AdminPage() {
|
|||||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tab, setTab] = useState<AdminTab>("features");
|
const [tab, setTab] = useState<AdminTab>("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
|
// 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
|
// first render of every cold load (typed URL, hard refresh, bookmark) while
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ export function LiveView() {
|
|||||||
}, [pointsById, nodes]);
|
}, [pointsById, nodes]);
|
||||||
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
|
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
|
||||||
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
|
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 nearById = useMemo(() => {
|
||||||
const m = new Map<string, NearCamera[]>();
|
const m = new Map<string, NearCamera[]>();
|
||||||
pointsById.forEach((pts, id) => m.set(id, nearestCameras(pts, cams.cameras)));
|
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 selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
||||||
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
||||||
const drillCam: Camera | null = link.cam
|
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;
|
: null;
|
||||||
|
|
||||||
// Region wall: pinned first, then the nearest camera to each major/moderate incident.
|
// Region wall: pinned first, then the nearest camera to each major/moderate incident.
|
||||||
const wallCameras = useMemo(() => {
|
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) {
|
for (const inc of sorted) {
|
||||||
if (severityRank(inc.severity) < 2) continue;
|
if (severityRank(inc.severity) < 2) continue;
|
||||||
const first = nearById.get(inc.incident_id)?.[0];
|
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 });
|
if (first && !out.some((o) => o.cam.id === first.cam.id)) out.push({ cam: first.cam, km: first.km });
|
||||||
}
|
}
|
||||||
return out.slice(0, 6);
|
return out.slice(0, 6);
|
||||||
}, [pinned, sorted, nearById]);
|
}, [servingPinned, sorted, nearById]);
|
||||||
|
|
||||||
// ── Navigation ────────────────────────────────────────────────────────────
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
|
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
|
||||||
|
|||||||
@@ -564,8 +564,16 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
|
|||||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||||
const zoom = useMapZoom(map);
|
const zoom = useMapZoom(map);
|
||||||
const { data, error, loaded } = useMapCameras(map, shown);
|
const { data, error, loaded } = useMapCameras(map, shown);
|
||||||
const [open, setOpen] = useState<OpenCamera[]>([]);
|
const [openAll, setOpen] = useState<OpenCamera[]>([]);
|
||||||
const feeds = useMemo(() => creditsByProvider(data.providers), [data.providers]);
|
// 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<Map<string, CameraProviderFeed>>(() => 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 pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
|
||||||
const cells = useMemo(
|
const cells = useMemo(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AircraftTrack, CallRecord, Camera, CameraCredit, CameraProviderFeed, IncidentRecord, Ny511Event, NodeRecord, VesselTrack,
|
AircraftTrack, CallRecord, Camera, CameraCredit, CameraProviderFeed, IncidentRecord, Ny511Event, NodeRecord, VesselTrack,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL } from "@/lib/severity";
|
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL } from "@/lib/severity";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { CameraFeed } from "@/components/CameraFeed";
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
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 }) {
|
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 (
|
return (
|
||||||
<p className="text-xs text-ink-muted">
|
<p className="text-xs text-ink-muted">
|
||||||
{servingCount === 0
|
{servingCount !== 0
|
||||||
? <>No camera provider is enabled. An admin turns them on under <Link href="/admin" className="underline">Admin → Camera providers</Link>.</>
|
? `No public camera within ${radiusKm} km.`
|
||||||
: `No public camera within ${radiusKm} km.`}
|
: isAdmin
|
||||||
|
? <>No camera provider is enabled. Turn one on under <Link href="/admin#cameras" className="underline">Admin → Camera providers</Link>.</>
|
||||||
|
: "No camera provider is enabled. A platform admin can turn them on."}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user