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:
parent
96de9092ab
commit
f63fb664dd
@ -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 () => {
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
AppWindow,
|
AppWindow,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
ExternalLink,
|
Pencil,
|
||||||
LogIn,
|
LogIn,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
@ -78,6 +78,18 @@ 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">
|
||||||
|
{/* 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 && (
|
{frame && (
|
||||||
<FrameImage
|
<FrameImage
|
||||||
frame={frame}
|
frame={frame}
|
||||||
@ -85,21 +97,24 @@ 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} />
|
|
||||||
<StatusBadge status={status} className="absolute left-1 top-1" />
|
|
||||||
{poppedOut ? (
|
{poppedOut ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-1.5 text-sm font-medium">
|
<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" />
|
<AppWindow aria-hidden className="size-4 shrink-0" />
|
||||||
Showing in pop-out
|
Showing in pop-out
|
||||||
</div>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
!frame &&
|
!frame &&
|
||||||
!paused && (
|
!paused && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
|
<span className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
|
||||||
Loading…
|
Loading…
|
||||||
</div>
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Outside that link: one control can't sit inside another. */}
|
||||||
|
<StatusBadge status={status} className="absolute left-1 top-1" />
|
||||||
|
<EditLink cam={cam} />
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user