Move card detail to the camera page; keep your status and icon tweaks

A card is now the picture with the status over its top left and Pop out
over its top right, the camera's name, and nothing else. The Details
disclosure is gone: the address, model and firmware, main stream,
location, web page link and Change login all live on the camera page,
which gained them beside Recordings and Refresh. Anything needing
attention still appears on the card.

The login form and web page link moved to src/app/camera-login.tsx so
both screens share them. StatusBadge now takes a className for placement
rather than positioning itself, keeps inline-flex so its icon and label
line up, and gains a faint ring so it stays legible over a bright frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-20 07:21:50 -05:00
parent d28694d545
commit 96de9092ab
10 changed files with 350 additions and 336 deletions

View File

@ -1189,3 +1189,6 @@
{"id":"evt-stnszeba14yw","type":"edge.added","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-c6d8r7n","to":"iss-t9twsxg"},"at":"2026-09-20T11:41:37.165Z","parents":["evt-xejefx0mvvv4"],"hash":"b16063efec294f51fc6e3b33a272edf8449798ac0335bbad223c055afb60e716"}
{"id":"evt-t0r3gbcnybbv","type":"verification.recorded","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"camera-card.tsx and camera-scanner.tsx now use the shared Disclosure (dec-0kchta3). Tests in camera-card.test.tsx (\"showing less by default\": what sits outside the details, what sits inside, and that a camera needing setup is not inside any details) and camera-scanner.test.tsx (options closed, summarised as \"5s, sweep\", both controls inside, the button outside). Closed is not hidden, so the existing assertions about that content still hold, which is the point. Full suite 967 tests pass, 99.66% lines; tsc, eslint and next build clean. Not yet seen in a browser."},"at":"2026-09-20T11:41:37.166Z","parents":["evt-stnszeba14yw"],"hash":"863c4152bfd102b11c1dc638ce574d96263239839122235c558afc5b0cf0d179"}
{"id":"evt-7fjdxzernfba","type":"node.status_changed","subject":"iss-t9twsxg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T11:41:38.231Z","parents":["evt-t0r3gbcnybbv"],"hash":"f22b614fb2b007d7e7391e3ba7d3d66318ad3f8a551d23d388c6cad2a2b0aef9"}
{"id":"evt-sbk9y0hkwxtk","type":"node.created","subject":"ver-agvm4d4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A dashboard card carries only the picture, the camera's name, its status and a pop-out icon; the address, model, streams, location, web page and login moved to the camera page; anything needing attention still appears on the card","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T12:21:39.217Z","parents":["evt-7fjdxzernfba"],"hash":"80a8b7a7403e59baf9a74bbb788b810004c85c5ad72936947a2e76d23c03d759"}
{"id":"evt-83mdjgywtrsf","type":"edge.added","subject":"ver-agvm4d4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-agvm4d4","to":"iss-t9twsxg"},"at":"2026-09-20T12:21:39.218Z","parents":["evt-sbk9y0hkwxtk"],"hash":"1c42236c6fc4149a9f8dae41e265adb12971c558822385e69549c1d68a302749"}
{"id":"evt-hsvaw2fzwdsy","type":"verification.recorded","subject":"ver-agvm4d4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"The Details disclosure is gone from camera-card.tsx (375 lines down to 219); the web page link and login form moved to src/app/camera-login.tsx, shared with the new src/app/cameras/[id]/camera-login-controls.tsx, which also shows the model, firmware and main stream on the camera page. Status sits over the picture, top left; Pop out is an icon top right with an accessible name. Tests: camera-card.test.tsx (\"is a picture, a name and a status, and nothing else\" asserts the address, location, device line, profile table, Web page, Change login and Details are all absent, and no details or table element exists), camera-login-controls.test.tsx (device and stream lines, web page link, opening and cancelling the form, saving, forgetting), page.test.tsx, icons.test.tsx. Full suite 967 tests pass, 99.61% lines; tsc, eslint and next build clean."},"at":"2026-09-20T12:21:39.219Z","parents":["evt-83mdjgywtrsf"],"hash":"3d6a927d090528d65631d47932511ce8246ecb27cf34da1c2ebd84811b2a84f1"}

View File

@ -41,52 +41,30 @@ beforeEach(() => {
});
describe("CameraCard", () => {
it("shows the camera's details, profiles and live frame", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
expect(screen.getByText("Porch")).toBeTruthy();
expect(screen.getByText("192.168.1.10:8080")).toBeTruthy();
expect(screen.getByText("Front door")).toBeTruthy();
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
expect(screen.getByText("1920×1080")).toBeTruthy();
await waitFor(() =>
expect(screen.getByRole("img", { name: "Porch" }).getAttribute("src")).toMatch(/^blob:/),
);
});
describe("showing less by default (iss-t9twsxg)", () => {
/** What a closed <details> holds: present for search and screen readers, not on show. */
const detail = () => document.querySelector("details")!;
it("leads with the picture, the name, the status and Pop out", async () => {
it("is a picture, a name and a status, and nothing else", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
await screen.findByText("Hikvision DS-2CD · fw V5.7");
const { container } = renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
await waitFor(() => expect(screen.getByRole("img", { name: "Porch" })).toBeTruthy());
const outside = detail().parentElement!.textContent!.replace(detail().textContent!, "");
expect(outside).toContain("Porch");
expect(outside).not.toContain("192.168.1.10:8080");
expect(screen.getByRole("link", { name: "Porch" })).toBeTruthy();
expect(screen.getByText("Live")).toBeTruthy();
// Pop out is an icon over the picture, not another line of text.
const popout = screen.getByRole("link", { name: "Pop out Porch" });
expect(popout.closest("details")).toBeNull();
expect(popout.querySelector("svg")?.getAttribute("class")).toContain("lucide-app-window");
expect(popout.className).toContain("absolute");
// Detail and settings live on the camera page now, not on a wall of cards.
const text = container.textContent!;
for (const gone of ["192.168.1.10:8080", "Front door", "Hikvision DS-2CD", "1920×1080", "Web page", "Change login", "Details"]) {
expect(text).not.toContain(gone);
}
expect(container.querySelector("details")).toBeNull();
expect(container.querySelector("table")).toBeNull();
});
it("keeps the address, streams, location, links and profiles for when they're asked for", async () => {
it("puts Pop out over the picture as an icon", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
await screen.findByText("Hikvision DS-2CD · fw V5.7");
expect(detail().open).toBe(false);
const inside = detail().textContent!;
for (const text of ["192.168.1.10:8080", "Front door", "Web page", "Change login", "1920×1080"]) {
expect(inside).toContain(text);
}
// Closed still says something useful.
expect(screen.getByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
const popout = await screen.findByRole("link", { name: "Pop out Porch" });
expect(popout.querySelector("svg")?.getAttribute("class")).toContain("lucide-external-link");
expect(popout.className).toContain("absolute");
});
it("never hides a camera that needs attention", async () => {
@ -106,7 +84,7 @@ describe("CameraCard", () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
const open = vi.spyOn(window, "open").mockReturnValue({ focus: vi.fn() } as unknown as Window);
renderWithQuery(<CameraCard cam={cam} intervalMs={500} />);
await screen.findByText("1920×1080");
await waitFor(() => expect(screen.getByRole("link", { name: "Pop out Porch" })).toBeTruthy());
const link = screen.getByRole("link", { name: "Pop out Porch" });
expect(link.getAttribute("href")).toBe(`/cameras/${cam.id}/live?refresh=500`);
@ -220,8 +198,7 @@ describe("CameraCard", () => {
expect(await screen.findByText("Setup required: camera not activated")).toBeTruthy();
active = true;
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
expect(screen.queryByText(/Setup required/)).toBeNull();
await waitFor(() => expect(screen.queryByText(/Setup required/)).toBeNull());
});
it("tells the user to sign in again when the app session ends, not to fix the camera login", async () => {
@ -262,8 +239,7 @@ describe("CameraCard", () => {
fireEvent.change(screen.getByPlaceholderText("Password"), { target: { value: "pw" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
expect(screen.queryByText("Camera login")).toBeNull();
await waitFor(() => expect(screen.queryByText("Camera login")).toBeNull());
const [, init] = calls(fetchMock, `PUT ${base}/credentials`)[0];
expect(JSON.parse(String(init?.body))).toEqual({ username: "saved-user", password: "pw" });
});
@ -305,77 +281,16 @@ describe("CameraCard", () => {
expect(await screen.findByText("Camera unreachable")).toBeTruthy();
});
it("opens from 'Change login' and can be cancelled", async () => {
stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: frame,
[`GET ${base}/credentials`]: () => json(noLogin),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
fireEvent.click(await screen.findByRole("button", { name: "Change login" }));
expect(screen.getByText("Camera login")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Forget saved login" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Camera login")).toBeNull();
});
it("forgets a stored login and re-checks the camera", async () => {
let stored = true;
const fetchMock = stubFetch({
[`GET ${base}/info`]: () =>
stored ? json(info) : json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/snapshot`]: frame,
[`GET ${base}/credentials`]: () =>
json(stored ? { source: "stored", username: "admin", hasPassword: true } : noLogin),
[`DELETE ${base}/credentials`]: () => {
stored = false;
return json(noLogin);
},
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
fireEvent.click(await screen.findByRole("button", { name: "Change login" }));
fireEvent.click(await screen.findByRole("button", { name: "Forget saved login" }));
expect(await screen.findByText("Wrong login")).toBeTruthy();
expect(calls(fetchMock, `DELETE ${base}/credentials`)).toHaveLength(1);
});
});
});
describe("name link and summary", () => {
describe("name link", () => {
it("links the camera name to its page", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
const link = screen.getByRole("link", { name: "Porch" });
expect(link.getAttribute("href")).toBe(`/cameras/${cam.id}`);
await screen.findByText("Hikvision DS-2CD · fw V5.7");
});
it("summarizes the main stream and hides the ONVIF placeholder brand", async () => {
const probeInfo = {
manufacturer: "ONVIF",
model: "I91ET",
firmwareVersion: "V5.8.10",
profiles: [
{ token: "Profile_1", name: "mainStream", encoding: "H264", width: 4096, height: 1860, fps: 20 },
{ token: "Profile_2", name: "subStream", encoding: "H264", width: 1200, height: 536, fps: 20 },
],
};
stubFetch({ [`GET ${base}/info`]: () => json(probeInfo), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(await screen.findByText("I91ET · fw V5.8.10")).toBeTruthy();
expect(screen.getByText("Main 4096×1860 · H.264 · 20 fps")).toBeTruthy();
expect(screen.queryByText(/ONVIF/)).toBeNull();
});
it("shows no summary or device line before info loads", () => {
stubFetch({ [`GET ${base}/info`]: () => new Promise(() => {}) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(screen.queryByText(/^Main /)).toBeNull();
expect(screen.queryByText(/ · fw /)).toBeNull();
await waitFor(() => expect(screen.getByRole("img", { name: "Porch" })).toBeTruthy());
});
});
@ -427,32 +342,3 @@ describe("status badge", () => {
await waitFor(() => expect(badge().getAttribute("data-status")).toBe("error"));
});
});
describe("two cameras", () => {
it("saving one camera's login refetches only that camera", async () => {
const other = { ...cam, id: "99999999-2222-3333-4444-555555555555", name: "Yard" };
const otherBase = `/api/cameras/${other.id}`;
const fetchMock = stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: frame,
[`GET ${base}/credentials`]: () => json(noLogin),
[`PUT ${base}/credentials`]: () =>
json({ source: "stored", username: "camera", hasPassword: true }),
[`GET ${otherBase}/info`]: () => json(info),
[`GET ${otherBase}/snapshot`]: frame,
});
renderWithQuery(
<>
<CameraCard cam={cam} intervalMs={60_000} />
<CameraCard cam={other} intervalMs={60_000} />
</>,
);
await waitFor(() => expect(screen.getAllByText("Hikvision DS-2CD · fw V5.7")).toHaveLength(2));
fireEvent.click(screen.getAllByRole("button", { name: "Change login" })[0]);
fireEvent.click(await screen.findByRole("button", { name: "Save" }));
await waitFor(() => expect(calls(fetchMock, `GET ${base}/info`)).toHaveLength(2));
expect(calls(fetchMock, `GET ${otherBase}/info`)).toHaveLength(1);
});
});

View File

@ -4,12 +4,8 @@ import {
AppWindow,
CircleAlert,
ExternalLink,
KeyRound,
LoaderCircle,
Lock,
LogIn,
RefreshCw,
Trash2,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
@ -18,34 +14,14 @@ import type { CameraSummary } from "@/lib/camera-registry";
import {
useCameraInfo,
useCameraSnapshot,
useClearCredentials,
useCredentialStatus,
useResetCamera,
useSaveCredentials,
type CameraProblem,
} from "./camera-queries";
import { cameraStatus, StatusBadge } from "./camera-status";
import Disclosure from "./disclosure";
import { deviceLine, streamSummary } from "./camera-summary";
import { cameraStatus, StatusBadge, type CameraStatus } from "./camera-status";
import { CredentialsForm, DEFAULT_USERNAME, WebUiLink } from "./camera-login";
import FrameImage from "./frame-image";
import { openPopout, popoutUrl, popoutWindowName, watchPopout } from "./popout";
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}
<ExternalLink aria-hidden className="ml-0.5 inline size-3 align-[-0.1em]" />
</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">
@ -90,6 +66,7 @@ function Snapshot({
poppedOut,
intervalMs,
aspect,
status,
}: {
cam: CameraSummary;
frame?: Blob;
@ -97,6 +74,7 @@ function Snapshot({
poppedOut: boolean;
intervalMs: number;
aspect?: number;
status: CameraStatus;
}) {
return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
@ -108,6 +86,7 @@ function Snapshot({
/>
)}
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
<StatusBadge status={status} className="absolute left-1 top-1" />
{poppedOut ? (
<div className="absolute inset-0 flex items-center justify-center gap-1.5 text-sm font-medium">
<AppWindow aria-hidden className="size-4 shrink-0" />
@ -148,116 +127,13 @@ function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalM
}}
aria-label={`Pop out ${name}`}
title={`Pop out ${name}`}
className="absolute right-2 top-2 rounded bg-black/50 p-1.5 text-white opacity-80 hover:opacity-100 focus-visible:opacity-100"
className="absolute right-1 top-1 rounded bg-black/50 p-1 text-white opacity-80 hover:opacity-100 focus-visible:opacity-100"
>
<AppWindow aria-hidden className="size-4" />
<ExternalLink aria-hidden className="size-5" />
</a>
);
}
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="flex items-center gap-1.5 font-medium">
<KeyRound aria-hidden className="size-4 shrink-0" />
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="flex items-center gap-1.5 text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={busy}
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{save.isPending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock aria-hidden className="size-4" />
)}
{save.isPending ? "Checking…" : "Save"}
</button>
{status.data?.source === "stored" && (
<button
type="button"
disabled={busy}
onClick={() => clear.mutate(undefined, { onSuccess: onSaved })}
className="inline-flex items-center gap-1.5 rounded px-3 py-1 underline disabled:opacity-50"
>
<Trash2 aria-hidden className="size-4" />
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,
@ -277,9 +153,6 @@ export default function CameraCard({
setEditingLogin(false);
resetCamera();
}
const device = info.data ? deviceLine(info.data) : null;
const summary = info.data ? streamSummary(info.data.profiles) : null;
const main = info.data?.profiles[0];
const aspect = main?.width && main.height ? main.width / main.height : undefined;
@ -295,62 +168,15 @@ export default function CameraCard({
poppedOut={poppedOut}
intervalMs={intervalMs}
aspect={aspect}
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
/>
<div className="flex items-center justify-between gap-2">
<Link href={`/cameras/${cam.id}`} className="font-medium hover:underline">
{cam.name ?? info.data?.model ?? "Unnamed camera"}
</Link>
<StatusBadge
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
/>
</div>
{/* Everything below is detail: useful when asked for, noise on a wall of cameras. */}
<Disclosure id={`camera-details-${cam.id}`} label="Details" summary={device ?? undefined}>
<div className="flex flex-col gap-1 text-sm text-zinc-600 dark:text-zinc-400">
<span className="font-mono">
{cam.host}:{cam.port}
</span>
{summary && <span>{summary}</span>}
{cam.location && <span>{cam.location}</span>}
<span className="flex gap-3 pt-1 text-xs">
<WebUiLink cam={cam}>Web page</WebUiLink>
{!showLogin && !showSetup && (
<button
onClick={() => setEditingLogin(true)}
className="inline-flex items-center gap-1 underline"
>
<KeyRound aria-hidden className="size-3.5" />
Change login
</button>
)}
</span>
{info.data && info.data.profiles.length > 0 && (
<table className="mt-1 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>
)}
</div>
</Disclosure>
{showSetup && <SetupPanel cam={cam} onCheckAgain={retry} />}
{showLogin && (

131
src/app/camera-login.tsx Normal file
View File

@ -0,0 +1,131 @@
"use client";
import { CircleAlert, ExternalLink, KeyRound, Lock, LoaderCircle, Trash2 } from "lucide-react";
import { useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import { useClearCredentials, useCredentialStatus, useSaveCredentials } from "./camera-queries";
/**
* The camera's own login and web page, used by the dashboard when a camera needs
* attention and by the camera page when someone chooses to change it (vrek gol-6x4ctm2).
*/
export 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}`}/`;
}
export function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.ReactNode }) {
return (
<a href={webUiUrl(cam)} target="_blank" rel="noopener noreferrer" className="underline">
{children}
<ExternalLink aria-hidden className="ml-0.5 inline size-3 align-[-0.1em]" />
</a>
);
}
export 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="flex items-center gap-1.5 font-medium">
<KeyRound aria-hidden className="size-4 shrink-0" />
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="flex items-center gap-1.5 text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={busy}
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{save.isPending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock aria-hidden className="size-4" />
)}
{save.isPending ? "Checking…" : "Save"}
</button>
{status.data?.source === "stored" && (
<button
type="button"
disabled={busy}
onClick={() => clear.mutate(undefined, { onSuccess: onSaved })}
className="inline-flex items-center gap-1.5 rounded px-3 py-1 underline disabled:opacity-50"
>
<Trash2 aria-hidden className="size-4" />
Forget saved login
</button>
)}
{onCancel && (
<button type="button" onClick={onCancel} className="rounded px-3 py-1 underline">
Cancel
</button>
)}
</div>
</form>
);
}

View File

@ -53,14 +53,18 @@ const STATUS: Record<CameraStatus, { label: string; Icon: LucideIcon; tone: stri
},
};
export function StatusBadge({ status }: { status: CameraStatus }) {
/**
* `className` is where it goes; the badge itself only decides how it looks. The ring keeps
* it legible over a bright snapshot, where the pale background alone washes out.
*/
export function StatusBadge({ status, className = "" }: { status: CameraStatus; className?: string }) {
const { label, Icon, tone, spin } = STATUS[status];
return (
<span
data-status={status}
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${tone}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-black/10 dark:ring-white/10 ${tone} ${className}`}
>
<Icon aria-hidden className={`size-3.5 ${spin ? "animate-spin motion-reduce:animate-none" : ""}`} />
<Icon aria-hidden className={`size-3 ${spin ? "animate-spin motion-reduce:animate-none" : ""}`} />
{label}
</span>
);

View File

@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { CameraSummary } from "@/lib/camera-registry";
import { calls, json, renderWithQuery, stubFetch } from "../../../../test/dom";
import CameraLoginControls from "./camera-login-controls";
/** What the dashboard card gave up when it became a picture, a name and a status. */
const cam: CameraSummary = {
id: "11111111-2222-3333-4444-555555555555",
host: "192.168.1.10",
port: 8080,
name: "Porch",
lastSeen: "2026-09-20T00:00:00.000Z",
};
const base = `/api/cameras/${cam.id}`;
const info = {
manufacturer: "Hikvision",
model: "DS-2CD",
firmwareVersion: "V5.7",
profiles: [{ token: "p1", name: "Main", encoding: "H264", width: 1920, height: 1080, fps: 25 }],
};
const noLogin = { source: "none", username: null, hasPassword: false };
const show = (only = cam) => renderWithQuery(<CameraLoginControls cam={only} />);
describe("CameraLoginControls", () => {
it("says what the camera is and what its main stream looks like", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info) });
show();
expect(await screen.findByText(/Hikvision DS-2CD · fw V5.7/)).toBeTruthy();
expect(screen.getByText(/Main 1920×1080 · H.264 · 25 fps/)).toBeTruthy();
});
it("hides the ONVIF placeholder brand, and says nothing before the camera answers", async () => {
stubFetch({
[`GET ${base}/info`]: () =>
json({ ...info, manufacturer: "ONVIF", model: "I91ET", firmwareVersion: "V5.8.10" }),
});
const { container } = show();
expect(container.textContent).not.toContain("fw");
expect(await screen.findByText(/I91ET · fw V5.8.10/)).toBeTruthy();
expect(screen.queryByText(/ONVIF/)).toBeNull();
});
it("links to the camera's own web page, which opens in a tab of its own", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info) });
show();
const link = screen.getByRole("link", { name: /Web page/ });
expect(link.getAttribute("href")).toBe("http://192.168.1.10:8080/");
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toContain("noopener");
});
it("opens the login form from Change login, and closes it again", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/credentials`]: () => json(noLogin) });
show();
expect(screen.queryByText("Camera login")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Change login" }));
expect(await screen.findByText("Camera login")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Camera login")).toBeNull();
});
it("saves a login and closes the form", async () => {
const fetchMock = stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/credentials`]: () => json(noLogin),
[`PUT ${base}/credentials`]: () => json({ source: "stored", username: "camera", hasPassword: true }),
});
show();
fireEvent.click(screen.getByRole("button", { name: "Change login" }));
fireEvent.change(await screen.findByPlaceholderText("Password"), { target: { value: "pw" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.queryByText("Camera login")).toBeNull());
const [, init] = calls(fetchMock, `PUT ${base}/credentials`)[0];
expect(JSON.parse(String(init?.body))).toEqual({ username: "camera", password: "pw" });
});
it("forgets a stored login and re-checks the camera", async () => {
const fetchMock = stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/credentials`]: () => json({ source: "stored", username: "saved", hasPassword: true }),
[`DELETE ${base}/credentials`]: () => json(noLogin),
});
show();
fireEvent.click(screen.getByRole("button", { name: "Change login" }));
fireEvent.click(await screen.findByRole("button", { name: "Forget saved login" }));
await waitFor(() => expect(calls(fetchMock, `DELETE ${base}/credentials`)).toHaveLength(1));
await waitFor(() => expect(calls(fetchMock, `GET ${base}/info`).length).toBeGreaterThan(1));
});
});

View File

@ -0,0 +1,47 @@
"use client";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import { useCameraInfo } from "../../camera-queries";
import { deviceLine, streamSummary } from "../../camera-summary";
import { CredentialsForm, WebUiLink } from "../../camera-login";
/**
* What the dashboard card gave up (vrek iss-t9twsxg): the model and firmware, the main
* stream in a line, the camera's own web page, and changing its login. A card is now a
* picture, a name and a status, so anything you go looking for lives here instead.
*/
export default function CameraLoginControls({ cam }: { cam: CameraSummary }) {
const [editing, setEditing] = useState(false);
const info = useCameraInfo(cam.id);
const device = info.data ? deviceLine(info.data) : null;
const streams = info.data ? streamSummary(info.data.profiles) : null;
return (
<div className="flex flex-col items-end gap-2">
{(device || streams) && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{device}
{device && streams ? " · " : ""}
{streams}
</p>
)}
<span className="flex items-center gap-3 text-sm">
<WebUiLink cam={cam}>Web page</WebUiLink>
{/* While the form is open it has its own Cancel; two would be one too many. */}
{!editing && (
<button onClick={() => setEditing(true)} className="inline-flex items-center gap-1 underline">
<KeyRound aria-hidden className="size-4" />
Change login
</button>
)}
</span>
{editing && (
<div className="w-full sm:w-96">
<CredentialsForm cam={cam} onSaved={() => setEditing(false)} onCancel={() => setEditing(false)} />
</div>
)}
</div>
);
}

View File

@ -11,6 +11,13 @@ vi.mock("@/lib/camera-registry", async (importOriginal) => ({
getCameraRecord,
}));
// Both client pieces are tested on their own; here they are markers carrying their props.
vi.mock("./camera-login-controls", () => ({
default: (props: { cam: { id: string; name?: string } }) => (
<div data-testid="login-controls">{JSON.stringify(props.cam)}</div>
),
}));
vi.mock("./live-panel", () => ({
default: (props: object) => <div data-testid="live">{JSON.stringify(props)}</div>,
}));
@ -67,6 +74,9 @@ describe("/cameras/[id]", () => {
expect(html).toContain('href="/"');
expect(html).toContain("All cameras");
expect(html).toContain(">Refresh</button>");
// The camera's own web page and login moved here from the dashboard card (iss-t9twsxg).
expect(html.replace(/&quot;/g, '"')).toContain(`"id":"${ID}"`);
expect(html).toContain("login-controls");
expect(html.replace(/&quot;/g, '"')).toContain(JSON.stringify({ cameraId: ID, name: "I91ET" }));
expect(html).toContain(JSON.stringify({ id: ID, host: "192.168.17.129", port: 80 }).replace(/"/g, "&quot;"));
for (const svg of html.match(/<svg[^>]*>/g)!) expect(svg).toContain('aria-hidden="true"');

View File

@ -6,6 +6,7 @@ import { requirePageAccess } from "@/lib/access";
import { isAllowedHost } from "@/lib/camera";
import { getCameraRecord, isValidCameraId } from "@/lib/camera-registry";
import { RefreshButton } from "./client-controls";
import CameraLoginControls from "./camera-login-controls";
import LivePanel from "./live-panel";
import { CameraSettingsSections } from "./settings-sections";
@ -39,15 +40,27 @@ export default async function CameraPage({ params }: PageProps<"/cameras/[id]">)
{record.location ? <span className="font-sans"> · {record.location}</span> : null}
</p>
</div>
<div className="flex items-center gap-3">
<Link
href={`/recordings?camera=${record.id}`}
className="inline-flex items-center gap-1 text-sm underline"
>
<Film aria-hidden className="size-4" />
Recordings
</Link>
<RefreshButton />
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-3">
<Link
href={`/recordings?camera=${record.id}`}
className="inline-flex items-center gap-1 text-sm underline"
>
<Film aria-hidden className="size-4" />
Recordings
</Link>
<RefreshButton />
</div>
<CameraLoginControls
cam={{
id: record.id,
host: record.host,
port: record.port,
name: record.name,
location: record.location,
lastSeen: record.lastSeen,
}}
/>
</div>
</div>

View File

@ -65,19 +65,17 @@ beforeEach(() => {
});
describe("camera card", () => {
it("marks Change login, the web page link, and the status badge", async () => {
it("marks the status badge and the pop-out icon, the only controls left on a card", async () => {
stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: () => new Response(new Blob(["x"])),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
const change = await screen.findByRole("button", { name: "Change login" });
expect(iconIn(change)).toContain("key-round");
const web = screen.getByRole("link", { name: "Web page" });
expect(iconIn(web)).toContain("external-link");
expect(web.textContent).not.toContain("↗");
allIconsDecorative();
// Icon-only, so its name has to say what it does and to which camera (pri-8ev5m89).
const popout = await screen.findByRole("link", { name: "Pop out Porch" });
expect(iconIn(popout)).toContain("external-link");
allIconsDecorative(popout);
});
it("marks the login form: title, Save, Forget, and errors", async () => {