From 96de9092ab214a10ffaac17805945b5092d34625 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sun, 20 Sep 2026 07:21:50 -0500 Subject: [PATCH] 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) --- .vrek/log.ndjson | 3 + src/app/camera-card.test.tsx | 156 ++------------ src/app/camera-card.tsx | 190 +----------------- src/app/camera-login.tsx | 131 ++++++++++++ src/app/camera-status.tsx | 10 +- .../[id]/camera-login-controls.test.tsx | 96 +++++++++ .../cameras/[id]/camera-login-controls.tsx | 47 +++++ src/app/cameras/[id]/page.test.tsx | 10 + src/app/cameras/[id]/page.tsx | 31 ++- src/app/icons.test.tsx | 12 +- 10 files changed, 350 insertions(+), 336 deletions(-) create mode 100644 src/app/camera-login.tsx create mode 100644 src/app/cameras/[id]/camera-login-controls.test.tsx create mode 100644 src/app/cameras/[id]/camera-login-controls.tsx diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 51233a2..17ee5d6 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -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"} diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx index 1e799cd..702d862 100644 --- a/src/app/camera-card.test.tsx +++ b/src/app/camera-card.test.tsx @@ -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(); - - 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
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(); - await screen.findByText("Hikvision DS-2CD · fw V5.7"); + const { container } = renderWithQuery(); + 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(); - 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(); - 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(); - - 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(); - - 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(); 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(); - - 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(); - 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( - <> - - - , - ); - 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); - }); -}); diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx index 436bae8..d5a811c 100644 --- a/src/app/camera-card.tsx +++ b/src/app/camera-card.tsx @@ -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 ( - - {children} - - - ); -} - function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) { return (
@@ -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 (
@@ -108,6 +86,7 @@ function Snapshot({ /> )} + {poppedOut ? (
@@ -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" > - + ); } -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(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 ( -
-
- - Camera login -
- {reason &&
{reason}
} -
- Use the ONVIF user you created on the camera ( - camera web page). -
- 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" - /> - 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 && ( -
- - {error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message} -
- )} -
- - {status.data?.source === "stored" && ( - - )} - {onCancel && ( - - )} -
-
- ); -} - 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 })} />
{cam.name ?? info.data?.model ?? "Unnamed camera"} -
- {/* Everything below is detail: useful when asked for, noise on a wall of cameras. */} - -
- - {cam.host}:{cam.port} - - {summary && {summary}} - {cam.location && {cam.location}} - - Web page - {!showLogin && !showSetup && ( - - )} - - {info.data && info.data.profiles.length > 0 && ( - - - - - - - - - - - {info.data.profiles.map((p) => ( - - - - - - - ))} - -
ProfileCodecResolutionFPS
{p.name ?? p.token}{p.encoding ?? "?"}{p.width && p.height ? `${p.width}×${p.height}` : "?"}{p.fps ?? "?"}
- )} -
-
- {showSetup && } {showLogin && ( diff --git a/src/app/camera-login.tsx b/src/app/camera-login.tsx new file mode 100644 index 0000000..971cbae --- /dev/null +++ b/src/app/camera-login.tsx @@ -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 ( + + {children} + + + ); +} + +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(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 ( +
+
+ + Camera login +
+ {reason &&
{reason}
} +
+ Use the ONVIF user you created on the camera ( + camera web page). +
+ 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" + /> + 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 && ( +
+ + {error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message} +
+ )} +
+ + {status.data?.source === "stored" && ( + + )} + {onCancel && ( + + )} +
+
+ ); +} + diff --git a/src/app/camera-status.tsx b/src/app/camera-status.tsx index 722c815..d70e190 100644 --- a/src/app/camera-status.tsx +++ b/src/app/camera-status.tsx @@ -53,14 +53,18 @@ const STATUS: Record - + {label} ); diff --git a/src/app/cameras/[id]/camera-login-controls.test.tsx b/src/app/cameras/[id]/camera-login-controls.test.tsx new file mode 100644 index 0000000..9915d30 --- /dev/null +++ b/src/app/cameras/[id]/camera-login-controls.test.tsx @@ -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(); + +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)); + }); +}); diff --git a/src/app/cameras/[id]/camera-login-controls.tsx b/src/app/cameras/[id]/camera-login-controls.tsx new file mode 100644 index 0000000..a203e4d --- /dev/null +++ b/src/app/cameras/[id]/camera-login-controls.tsx @@ -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 ( +
+ {(device || streams) && ( +

+ {device} + {device && streams ? " · " : ""} + {streams} +

+ )} + + Web page + {/* While the form is open it has its own Cancel; two would be one too many. */} + {!editing && ( + + )} + + {editing && ( +
+ setEditing(false)} onCancel={() => setEditing(false)} /> +
+ )} +
+ ); +} diff --git a/src/app/cameras/[id]/page.test.tsx b/src/app/cameras/[id]/page.test.tsx index 645851c..ee8ae75 100644 --- a/src/app/cameras/[id]/page.test.tsx +++ b/src/app/cameras/[id]/page.test.tsx @@ -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 } }) => ( +
{JSON.stringify(props.cam)}
+ ), +})); + vi.mock("./live-panel", () => ({ default: (props: object) =>
{JSON.stringify(props)}
, })); @@ -67,6 +74,9 @@ describe("/cameras/[id]", () => { expect(html).toContain('href="/"'); expect(html).toContain("All cameras"); expect(html).toContain(">Refresh"); + // The camera's own web page and login moved here from the dashboard card (iss-t9twsxg). + expect(html.replace(/"/g, '"')).toContain(`"id":"${ID}"`); + expect(html).toContain("login-controls"); expect(html.replace(/"/g, '"')).toContain(JSON.stringify({ cameraId: ID, name: "I91ET" })); expect(html).toContain(JSON.stringify({ id: ID, host: "192.168.17.129", port: 80 }).replace(/"/g, """)); for (const svg of html.match(/]*>/g)!) expect(svg).toContain('aria-hidden="true"'); diff --git a/src/app/cameras/[id]/page.tsx b/src/app/cameras/[id]/page.tsx index 82dbab5..b30eece 100644 --- a/src/app/cameras/[id]/page.tsx +++ b/src/app/cameras/[id]/page.tsx @@ -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 ? · {record.location} : null}

-
- - - Recordings - - +
+
+ + + Recordings + + +
+
diff --git a/src/app/icons.test.tsx b/src/app/icons.test.tsx index 13807db..408f158 100644 --- a/src/app/icons.test.tsx +++ b/src/app/icons.test.tsx @@ -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(); - 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 () => {