@@ -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 (
-
- );
-}
-
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 && (
- setEditingLogin(true)}
- className="inline-flex items-center gap-1 underline"
- >
-
- Change login
-
- )}
-
- {info.data && info.data.profiles.length > 0 && (
-
-
-
- Profile
- Codec
- Resolution
- FPS
-
-
-
- {info.data.profiles.map((p) => (
-
- {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 (
+
+ );
+}
+
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 && (
+ setEditing(true)} className="inline-flex items-center gap-1 underline">
+
+ Change login
+
+ )}
+
+ {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 () => {