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 <camera>". The
pop-out icon is gone, so a card carries one icon, not two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-20 07:25:29 -05:00
parent 96de9092ab
commit f63fb664dd
3 changed files with 69 additions and 45 deletions

View File

@ -52,19 +52,33 @@ describe("CameraCard", () => {
// Detail and settings live on the camera page now, not on a wall of cards. // Detail and settings live on the camera page now, not on a wall of cards.
const text = container.textContent!; 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(text).not.toContain(gone);
} }
expect(container.querySelector("details")).toBeNull(); expect(container.querySelector("details")).toBeNull();
expect(container.querySelector("table")).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 }); stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />); renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
const popout = await screen.findByRole("link", { name: "Pop out Porch" });
expect(popout.querySelector("svg")?.getAttribute("class")).toContain("lucide-external-link"); const image = await screen.findByRole("img", { name: "Porch" });
expect(popout.className).toContain("absolute"); 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(<CameraCard cam={cam} intervalMs={1000} />);
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 () => { it("never hides a camera that needs attention", async () => {

View File

@ -3,7 +3,7 @@
import { import {
AppWindow, AppWindow,
CircleAlert, CircleAlert,
ExternalLink, Pencil,
LogIn, LogIn,
RefreshCw, RefreshCw,
TriangleAlert, TriangleAlert,
@ -78,28 +78,43 @@ function Snapshot({
}) { }) {
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">
{frame && ( {/* The picture itself opens the live pop-out: a link, so keyboard and a blocked
<FrameImage popup both still work. */}
frame={frame} <a
alt={cam.name ?? cam.host} href={popoutUrl(cam.id, intervalMs)}
className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`} target={popoutWindowName(cam.id)}
/> onClick={(e) => {
)} if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault();
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} /> }}
aria-label={`Pop out ${cam.name ?? cam.host}`}
title={`Pop out ${cam.name ?? cam.host}`}
className="absolute inset-0 block"
>
{frame && (
<FrameImage
frame={frame}
alt={cam.name ?? cam.host}
className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`}
/>
)}
{poppedOut ? (
<span className="absolute inset-0 flex items-center justify-center gap-1.5 text-sm font-medium">
<AppWindow aria-hidden className="size-4 shrink-0" />
Showing in pop-out
</span>
) : (
!frame &&
!paused && (
<span className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
</span>
)
)}
</a>
{/* Outside that link: one control can't sit inside another. */}
<StatusBadge status={status} className="absolute left-1 top-1" /> <StatusBadge status={status} className="absolute left-1 top-1" />
{poppedOut ? ( <EditLink cam={cam} />
<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" />
Showing in pop-out
</div>
) : (
!frame &&
!paused && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
</div>
)
)}
</div> </div>
); );
} }
@ -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 * Opens the camera's own page, where its settings and login live. Icon-only, so it carries
* blocked; with JavaScript it opens a popup window sized to the main stream. Icon-only, so * a name saying what it does and to which camera (pri-8ev5m89).
* it carries a name that says what it does (pri-8ev5m89).
*/ */
function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalMs: number; aspect?: number }) { function EditLink({ cam }: { cam: CameraSummary }) {
const name = cam.name ?? cam.host; const name = cam.name ?? cam.host;
return ( return (
<a <Link
href={popoutUrl(cam.id, intervalMs)} href={`/cameras/${cam.id}`}
target={popoutWindowName(cam.id)} aria-label={`Settings for ${name}`}
onClick={(e) => { title={`Settings for ${name}`}
if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault();
}}
aria-label={`Pop out ${name}`}
title={`Pop out ${name}`}
className="absolute right-1 top-1 rounded bg-black/50 p-1 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"
> >
<ExternalLink aria-hidden className="size-5" /> <Pencil aria-hidden className="size-5" />
</a> </Link>
); );
} }

View File

@ -65,7 +65,7 @@ beforeEach(() => {
}); });
describe("camera card", () => { 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({ stubFetch({
[`GET ${base}/info`]: () => json(info), [`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: () => new Response(new Blob(["x"])), [`GET ${base}/snapshot`]: () => new Response(new Blob(["x"])),
@ -73,9 +73,9 @@ describe("camera card", () => {
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />); renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
// Icon-only, so its name has to say what it does and to which camera (pri-8ev5m89). // 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" }); const settings = await screen.findByRole("link", { name: "Settings for Porch" });
expect(iconIn(popout)).toContain("external-link"); expect(iconIn(settings)).toContain("pencil");
allIconsDecorative(popout); allIconsDecorative(settings);
}); });
it("marks the login form: title, Save, Forget, and errors", async () => { it("marks the login form: title, Save, Forget, and errors", async () => {