From d28694d5452dabaff094041b52eb69e4d568440a Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sun, 20 Sep 2026 06:46:18 -0500 Subject: [PATCH] Move Pop out onto the picture as an icon It sits over the top right of the snapshot instead of taking a line beside the camera's name. Icon-only, so it carries a name that says what it does and which camera ("Pop out Porch"), and it stays an ordinary link so a blocked popup still opens a tab. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/camera-card.test.tsx | 11 ++++++++--- src/app/camera-card.tsx | 29 ++++++++++++++++++----------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx index bfcce5a..1e799cd 100644 --- a/src/app/camera-card.test.tsx +++ b/src/app/camera-card.test.tsx @@ -66,8 +66,13 @@ describe("CameraCard", () => { const outside = detail().parentElement!.textContent!.replace(detail().textContent!, ""); expect(outside).toContain("Porch"); - expect(outside).toContain("Pop out"); expect(outside).not.toContain("192.168.1.10:8080"); + + // 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"); }); it("keeps the address, streams, location, links and profiles for when they're asked for", async () => { @@ -103,7 +108,7 @@ describe("CameraCard", () => { renderWithQuery(); await screen.findByText("1920×1080"); - const link = screen.getByRole("link", { name: "Pop out" }); + const link = screen.getByRole("link", { name: "Pop out Porch" }); expect(link.getAttribute("href")).toBe(`/cameras/${cam.id}/live?refresh=500`); expect(link.getAttribute("target")).toBe(`camera-${cam.id}`); expect(fireEvent.click(link)).toBe(false); // default prevented: the popup replaced the link @@ -121,7 +126,7 @@ describe("CameraCard", () => { stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame }); vi.spyOn(window, "open").mockReturnValue(null); renderWithQuery(); - expect(fireEvent.click(screen.getByRole("link", { name: "Pop out" }))).toBe(true); + expect(fireEvent.click(screen.getByRole("link", { name: "Pop out Porch" }))).toBe(true); }); it("pauses its own polling while the camera shows in a pop-out, and resumes after", async () => { diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx index fc9c30c..436bae8 100644 --- a/src/app/camera-card.tsx +++ b/src/app/camera-card.tsx @@ -88,11 +88,15 @@ function Snapshot({ frame, paused, poppedOut, + intervalMs, + aspect, }: { cam: CameraSummary; frame?: Blob; paused: boolean; poppedOut: boolean; + intervalMs: number; + aspect?: number; }) { return (
@@ -103,6 +107,7 @@ function Snapshot({ className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`} /> )} + {poppedOut ? (
@@ -128,10 +133,12 @@ function usePoppedOut(id: string) { } /** - * A link, so it still opens a tab if popups are blocked; with JavaScript it opens a - * popup window sized to the main stream. + * 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). */ function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalMs: number; aspect?: number }) { + const name = cam.name ?? cam.host; return ( { if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault(); }} - className="inline-flex items-center gap-1 underline" + 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" > - - Pop out + ); } @@ -285,18 +293,17 @@ export default function CameraCard({ frame={snapshot.data} paused={problem !== null || !info.isSuccess} poppedOut={poppedOut} + intervalMs={intervalMs} + aspect={aspect} />
{cam.name ?? info.data?.model ?? "Unnamed camera"} - - - - +
{/* Everything below is detail: useful when asked for, noise on a wall of cameras. */}