cameras/src/app/camera-card.tsx
Michael Mainguy 870b4cd2af Move dashboard client onto React Query hooks
Adds src/app/camera-queries.ts: React Query hooks over our own routes,
with each camera's queries keyed ['camera', id]. The camera card,
snapshot polling, login form and network scan use these hooks instead
of hand-rolled fetch/useEffect state (vrek iss-2fm6x2y, iss-ksxmctm,
iss-m032zwq, iss-8hfq2y2).

- Snapshot polling pauses in hidden tabs, never overlaps a slow
  frame, and stops after a failure until Retry. Each frame's object
  URL is created and revoked in one effect, so none leak under Strict
  Mode.
- Saving or forgetting a login resets only that camera's queries.
- New "Forget saved login" action, shown for stored logins.
- Fix: a scan timeout typed below 1 s now clamps to 1 s instead of
  falling back to 5 s.

Adds jsdom component tests (test/dom.tsx helpers): 93 tests, line
coverage 54.3%. Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 09:01:34 -05:00

283 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useRef, useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import {
useCameraInfo,
useCameraSnapshot,
useClearCredentials,
useCredentialStatus,
useResetCamera,
useSaveCredentials,
type CameraProblem,
} from "./camera-queries";
const DEFAULT_USERNAME = "camera";
/** The camera's own web interface; it can't be framed (X-Frame-Options: SAMEORIGIN). */
function webUiUrl(cam: CameraSummary) {
return `http://${cam.host}${cam.port === 80 ? "" : `:${cam.port}`}/`;
}
function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.ReactNode }) {
return (
<a href={webUiUrl(cam)} target="_blank" rel="noopener noreferrer" className="underline">
{children} ↗
</a>
);
}
function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) {
return (
<div className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="font-medium">Setup required: camera not activated</div>
<ol className="list-decimal space-y-1 pl-5 text-zinc-700 dark:text-zinc-300">
<li>
<WebUiLink cam={cam}>Open the camera&apos;s web page</WebUiLink> and activate it by
choosing an admin password. Save it in your password manager; this app never stores
it.
</li>
<li>
Enable ONVIF (usually Configuration → Network → Advanced Settings → Integration
Protocol).
</li>
<li>
On the same page, add an ONVIF user named <code>{DEFAULT_USERNAME}</code> with the{" "}
<strong>Operator</strong> level.
</li>
<li>Come back here and click Check again, then enter that user&apos;s login.</li>
</ol>
<div>
<button
onClick={onCheckAgain}
className="rounded bg-black px-3 py-1 text-white dark:bg-white dark:text-black"
>
Check again
</button>
</div>
</div>
);
}
/** Shows the latest frame; polling itself lives in useCameraSnapshot. */
function Snapshot({ cam, frame, paused }: { cam: CameraSummary; frame?: Blob; paused: boolean }) {
const imgRef = useRef<HTMLImageElement>(null);
// Each frame gets one object URL, created and revoked in the same effect so it is freed
// exactly once, including under Strict Mode's double rendering.
useEffect(() => {
const img = imgRef.current;
if (!frame || !img) return;
const url = URL.createObjectURL(frame);
img.src = url;
return () => URL.revokeObjectURL(url);
}, [frame]);
return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
{frame && (
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
<img
ref={imgRef}
alt={cam.name ?? cam.host}
className={`h-full w-full object-contain ${paused ? "opacity-40" : ""}`}
/>
)}
{!frame && !paused && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
</div>
)}
</div>
);
}
function CredentialsForm({
cam,
reason,
onSaved,
onCancel,
}: {
cam: CameraSummary;
reason?: string;
/** Called after a save or clear; the camera's queries have already been reset. */
onSaved: () => void;
onCancel?: () => void;
}) {
const status = useCredentialStatus(cam.id);
const save = useSaveCredentials(cam.id);
const clear = useClearCredentials(cam.id);
// null until the user types, so the saved username (never the password) can prefill it.
const [typedUsername, setTypedUsername] = useState<string | null>(null);
const [password, setPassword] = useState("");
const username = typedUsername ?? status.data?.username ?? DEFAULT_USERNAME;
function submit(e: React.FormEvent) {
e.preventDefault();
save.mutate(
{ username, password },
{
onSuccess: () => {
setPassword("");
onSaved();
},
},
);
}
const error = save.error ?? clear.error;
const busy = save.isPending || clear.isPending;
return (
<form onSubmit={submit} className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="font-medium">Camera login</div>
{reason && <div className="text-xs text-zinc-500">{reason}</div>}
<div className="text-xs text-zinc-500">
Use the ONVIF user you created on the camera (
<WebUiLink cam={cam}>camera web page</WebUiLink>).
</div>
<input
value={username}
onChange={(e) => setTypedUsername(e.target.value)}
placeholder="Username"
autoComplete="off"
required
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
autoComplete="new-password"
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
{error && (
<div className="text-red-600">
{error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={busy}
className="rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{save.isPending ? "Checking…" : "Save"}
</button>
{status.data?.source === "stored" && (
<button
type="button"
disabled={busy}
onClick={() => clear.mutate(undefined, { onSuccess: onSaved })}
className="rounded px-3 py-1 underline disabled:opacity-50"
>
Forget saved login
</button>
)}
{onCancel && (
<button type="button" onClick={onCancel} className="rounded px-3 py-1 underline">
Cancel
</button>
)}
</div>
</form>
);
}
export default function CameraCard({
cam,
intervalMs,
}: {
cam: CameraSummary;
intervalMs: number;
}) {
const info = useCameraInfo(cam.id);
const snapshot = useCameraSnapshot(cam.id, intervalMs, info.isSuccess);
const resetCamera = useResetCamera(cam.id);
const [editingLogin, setEditingLogin] = useState(false);
const problem: CameraProblem | null = info.error ?? snapshot.error;
function retry() {
setEditingLogin(false);
resetCamera();
}
const showSetup = problem?.kind === "inactive";
const showLogin = !showSetup && (problem?.kind === "auth" || editingLogin);
return (
<li className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<Snapshot cam={cam} frame={snapshot.data} paused={problem !== null || !info.isSuccess} />
<div>
<div className="font-medium">{cam.name ?? info.data?.model ?? "Unnamed camera"}</div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm">
{cam.host}:{cam.port}
</span>
<span className="flex gap-3 text-xs">
<WebUiLink cam={cam}>Web page</WebUiLink>
{!showLogin && !showSetup && (
<button onClick={() => setEditingLogin(true)} className="underline">
Change login
</button>
)}
</span>
</div>
{info.data?.manufacturer && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{info.data.manufacturer} {info.data.model} · fw {info.data.firmwareVersion}
</div>
)}
{cam.location && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">{cam.location}</div>
)}
</div>
{showSetup && <SetupPanel cam={cam} onCheckAgain={retry} />}
{showLogin && (
<CredentialsForm
cam={cam}
reason={problem?.kind === "auth" ? problem.message : undefined}
onSaved={() => setEditingLogin(false)}
onCancel={problem?.kind === "auth" ? undefined : () => setEditingLogin(false)}
/>
)}
{problem?.kind === "error" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span>Stopped: {problem.message}</span>
<button onClick={retry} className="underline">
Retry
</button>
</div>
)}
{info.data && info.data.profiles.length > 0 && (
<table className="text-xs">
<thead className="text-left text-zinc-500">
<tr>
<th className="font-normal">Profile</th>
<th className="font-normal">Codec</th>
<th className="font-normal">Resolution</th>
<th className="font-normal">FPS</th>
</tr>
</thead>
<tbody className="font-mono">
{info.data.profiles.map((p) => (
<tr key={p.token}>
<td>{p.name ?? p.token}</td>
<td>{p.encoding ?? "?"}</td>
<td>{p.width && p.height ? `${p.width}×${p.height}` : "?"}</td>
<td>{p.fps ?? "?"}</td>
</tr>
))}
</tbody>
</table>
)}
</li>
);
}