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:
parent
efe49d7363
commit
d28694d545
@ -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 () => {
|
||||||
|
|||||||
@ -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. */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user