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.
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(<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");
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(<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 () => {

View File

@ -3,7 +3,7 @@
import {
AppWindow,
CircleAlert,
ExternalLink,
Pencil,
LogIn,
RefreshCw,
TriangleAlert,
@ -78,28 +78,43 @@ function Snapshot({
}) {
return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
{frame && (
<FrameImage
frame={frame}
alt={cam.name ?? cam.host}
className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`}
/>
)}
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
{/* The picture itself opens the live pop-out: a link, so keyboard and a blocked
popup both still work. */}
<a
href={popoutUrl(cam.id, intervalMs)}
target={popoutWindowName(cam.id)}
onClick={(e) => {
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 && (
<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" />
{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" />
Showing in pop-out
</div>
) : (
!frame &&
!paused && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
</div>
)
)}
<EditLink cam={cam} />
</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
* 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 (
<a
href={popoutUrl(cam.id, intervalMs)}
target={popoutWindowName(cam.id)}
onClick={(e) => {
if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault();
}}
aria-label={`Pop out ${name}`}
title={`Pop out ${name}`}
<Link
href={`/cameras/${cam.id}`}
aria-label={`Settings for ${name}`}
title={`Settings for ${name}`}
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" />
</a>
<Pencil aria-hidden className="size-5" />
</Link>
);
}

View File

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