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) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-20 06:46:18 -05:00
parent efe49d7363
commit d28694d545
2 changed files with 26 additions and 14 deletions

View File

@ -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(<CameraCard cam={cam} intervalMs={500} />);
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(<CameraCard cam={cam} intervalMs={500} />);
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 () => {

View File

@ -88,11 +88,15 @@ function Snapshot({
frame,
paused,
poppedOut,
intervalMs,
aspect,
}: {
cam: CameraSummary;
frame?: Blob;
paused: boolean;
poppedOut: boolean;
intervalMs: number;
aspect?: number;
}) {
return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
@ -103,6 +107,7 @@ function Snapshot({
className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`}
/>
)}
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
{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" />
@ -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 (
<a
href={popoutUrl(cam.id, intervalMs)}
@ -139,10 +146,11 @@ function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalM
onClick={(e) => {
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"
>
<AppWindow aria-hidden className="size-3.5" />
Pop out
<AppWindow aria-hidden className="size-4" />
</a>
);
}
@ -285,18 +293,17 @@ export default function CameraCard({
frame={snapshot.data}
paused={problem !== null || !info.isSuccess}
poppedOut={poppedOut}
intervalMs={intervalMs}
aspect={aspect}
/>
<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>
<span className="flex items-center gap-3">
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
<StatusBadge
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
/>
</span>
<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. */}