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>
283 lines
9.3 KiB
TypeScript
283 lines
9.3 KiB
TypeScript
"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'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'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>
|
||
);
|
||
}
|