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!, ""); const outside = detail().parentElement!.textContent!.replace(detail().textContent!, "");
expect(outside).toContain("Porch"); expect(outside).toContain("Porch");
expect(outside).toContain("Pop out");
expect(outside).not.toContain("192.168.1.10:8080"); 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 () => { 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} />); renderWithQuery(<CameraCard cam={cam} intervalMs={500} />);
await screen.findByText("1920×1080"); 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("href")).toBe(`/cameras/${cam.id}/live?refresh=500`);
expect(link.getAttribute("target")).toBe(`camera-${cam.id}`); expect(link.getAttribute("target")).toBe(`camera-${cam.id}`);
expect(fireEvent.click(link)).toBe(false); // default prevented: the popup replaced the link 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 }); stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
vi.spyOn(window, "open").mockReturnValue(null); vi.spyOn(window, "open").mockReturnValue(null);
renderWithQuery(<CameraCard cam={cam} intervalMs={500} />); 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 () => { 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, frame,
paused, paused,
poppedOut, poppedOut,
intervalMs,
aspect,
}: { }: {
cam: CameraSummary; cam: CameraSummary;
frame?: Blob; frame?: Blob;
paused: boolean; paused: boolean;
poppedOut: boolean; poppedOut: boolean;
intervalMs: number;
aspect?: number;
}) { }) {
return ( return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900"> <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" : ""}`} className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`}
/> />
)} )}
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
{poppedOut ? ( {poppedOut ? (
<div className="absolute inset-0 flex items-center justify-center gap-1.5 text-sm font-medium"> <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" /> <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 * Sits over the top-right of the picture. A link, so it still opens a tab if popups are
* popup window sized to the main stream. * 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 }) { function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalMs: number; aspect?: number }) {
const name = cam.name ?? cam.host;
return ( return (
<a <a
href={popoutUrl(cam.id, intervalMs)} href={popoutUrl(cam.id, intervalMs)}
@ -139,10 +146,11 @@ function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalM
onClick={(e) => { onClick={(e) => {
if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault(); 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" /> <AppWindow aria-hidden className="size-4" />
Pop out
</a> </a>
); );
} }
@ -285,18 +293,17 @@ export default function CameraCard({
frame={snapshot.data} frame={snapshot.data}
paused={problem !== null || !info.isSuccess} paused={problem !== null || !info.isSuccess}
poppedOut={poppedOut} poppedOut={poppedOut}
intervalMs={intervalMs}
aspect={aspect}
/> />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Link href={`/cameras/${cam.id}`} className="font-medium hover:underline"> <Link href={`/cameras/${cam.id}`} className="font-medium hover:underline">
{cam.name ?? info.data?.model ?? "Unnamed camera"} {cam.name ?? info.data?.model ?? "Unnamed camera"}
</Link> </Link>
<span className="flex items-center gap-3">
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
<StatusBadge <StatusBadge
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })} status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
/> />
</span>
</div> </div>
{/* Everything below is detail: useful when asked for, noise on a wall of cameras. */} {/* Everything below is detail: useful when asked for, noise on a wall of cameras. */}