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.
|
||||
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 () => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user