From f63fb664dd713c5e2590c1b63d8e440c3cd7fd70 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sun, 20 Sep 2026 07:25:29 -0500 Subject: [PATCH] Click the picture to pop out; put settings behind an icon The snapshot is now the way into the live pop-out, as a link, so keyboard and a blocked popup both still work. The icon over the top right is the camera's own page instead: a pencil, named "Settings for ". The pop-out icon is gone, so a card carries one icon, not two. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/camera-card.test.tsx | 24 ++++++++--- src/app/camera-card.tsx | 82 ++++++++++++++++++++---------------- src/app/icons.test.tsx | 8 ++-- 3 files changed, 69 insertions(+), 45 deletions(-) diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx index 702d862..23a46c3 100644 --- a/src/app/camera-card.test.tsx +++ b/src/app/camera-card.test.tsx @@ -52,19 +52,33 @@ describe("CameraCard", () => { // 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"]) { + for (const gone of ["192.168.1.10:8080", "Front door", "Hikvision DS-2CD", "1920×1080", "Web page", "Change login", "Details", "Pop out"]) { expect(text).not.toContain(gone); } expect(container.querySelector("details")).toBeNull(); expect(container.querySelector("table")).toBeNull(); }); - it("puts Pop out over the picture as an icon", async () => { + it("makes the picture itself the way into the live pop-out", async () => { stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame }); renderWithQuery(); - 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"); + + const image = await screen.findByRole("img", { name: "Porch" }); + const picture = screen.getByRole("link", { name: "Pop out Porch" }); + expect(picture.contains(image)).toBe(true); + expect(picture.getAttribute("href")).toBe(`/cameras/${cam.id}/live?refresh=1000`); + }); + + it("offers the camera's own page from an icon over the picture", async () => { + stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame }); + renderWithQuery(); + + const settings = screen.getByRole("link", { name: "Settings for Porch" }); + expect(settings.getAttribute("href")).toBe(`/cameras/${cam.id}`); + expect(settings.querySelector("svg")?.getAttribute("class")).toContain("lucide-pencil"); + expect(settings.className).toContain("absolute"); + // One control can't sit inside another. + expect(settings.closest("a[aria-label^='Pop out']")).toBeNull(); }); it("never hides a camera that needs attention", async () => { diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx index d5a811c..e26293c 100644 --- a/src/app/camera-card.tsx +++ b/src/app/camera-card.tsx @@ -3,7 +3,7 @@ import { AppWindow, CircleAlert, - ExternalLink, + Pencil, LogIn, RefreshCw, TriangleAlert, @@ -78,28 +78,43 @@ function Snapshot({ }) { return (
- {frame && ( - - )} - + {/* The picture itself opens the live pop-out: a link, so keyboard and a blocked + popup both still work. */} + { + if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault(); + }} + aria-label={`Pop out ${cam.name ?? cam.host}`} + title={`Pop out ${cam.name ?? cam.host}`} + className="absolute inset-0 block" + > + {frame && ( + + )} + {poppedOut ? ( + + + Showing in pop-out + + ) : ( + !frame && + !paused && ( + + Loading… + + ) + )} + + + {/* Outside that link: one control can't sit inside another. */} - {poppedOut ? ( -
- - Showing in pop-out -
- ) : ( - !frame && - !paused && ( -
- Loading… -
- ) - )} +
); } @@ -112,25 +127,20 @@ function usePoppedOut(id: string) { } /** - * Sits over the top-right of the picture. A link, so it still opens a tab if popups are - * blocked; with JavaScript it opens a popup window sized to the main stream. Icon-only, so - * it carries a name that says what it does (pri-8ev5m89). + * Opens the camera's own page, where its settings and login live. Icon-only, so it carries + * a name saying what it does and to which camera (pri-8ev5m89). */ -function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalMs: number; aspect?: number }) { +function EditLink({ cam }: { cam: CameraSummary }) { const name = cam.name ?? cam.host; return ( - { - if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault(); - }} - aria-label={`Pop out ${name}`} - title={`Pop out ${name}`} + - - + + ); } diff --git a/src/app/icons.test.tsx b/src/app/icons.test.tsx index 408f158..0a4ebef 100644 --- a/src/app/icons.test.tsx +++ b/src/app/icons.test.tsx @@ -65,7 +65,7 @@ beforeEach(() => { }); describe("camera card", () => { - it("marks the status badge and the pop-out icon, the only controls left on a card", async () => { + it("marks the settings icon, the only icon control left on a card", async () => { stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: () => new Response(new Blob(["x"])), @@ -73,9 +73,9 @@ describe("camera card", () => { renderWithQuery(); // 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); + const settings = await screen.findByRole("link", { name: "Settings for Porch" }); + expect(iconIn(settings)).toContain("pencil"); + allIconsDecorative(settings); }); it("marks the login form: title, Save, Forget, and errors", async () => {