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!, "");
|
||||
expect(outside).toContain("Porch");
|
||||
expect(outside).toContain("Pop out");
|
||||
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 () => {
|
||||
@ -103,7 +108,7 @@ describe("CameraCard", () => {
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={500} />);
|
||||
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("target")).toBe(`camera-${cam.id}`);
|
||||
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 });
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
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 () => {
|
||||
|
||||
@ -88,11 +88,15 @@ function Snapshot({
|
||||
frame,
|
||||
paused,
|
||||
poppedOut,
|
||||
intervalMs,
|
||||
aspect,
|
||||
}: {
|
||||
cam: CameraSummary;
|
||||
frame?: Blob;
|
||||
paused: boolean;
|
||||
poppedOut: boolean;
|
||||
intervalMs: number;
|
||||
aspect?: number;
|
||||
}) {
|
||||
return (
|
||||
<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" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
|
||||
{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" />
|
||||
@ -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
|
||||
* popup window sized to the main stream.
|
||||
* 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).
|
||||
*/
|
||||
function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalMs: number; aspect?: number }) {
|
||||
const name = cam.name ?? cam.host;
|
||||
return (
|
||||
<a
|
||||
href={popoutUrl(cam.id, intervalMs)}
|
||||
@ -139,10 +146,11 @@ function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalM
|
||||
onClick={(e) => {
|
||||
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" />
|
||||
Pop out
|
||||
<AppWindow aria-hidden className="size-4" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -285,18 +293,17 @@ export default function CameraCard({
|
||||
frame={snapshot.data}
|
||||
paused={problem !== null || !info.isSuccess}
|
||||
poppedOut={poppedOut}
|
||||
intervalMs={intervalMs}
|
||||
aspect={aspect}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link href={`/cameras/${cam.id}`} className="font-medium hover:underline">
|
||||
{cam.name ?? info.data?.model ?? "Unnamed camera"}
|
||||
</Link>
|
||||
<span className="flex items-center gap-3">
|
||||
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
|
||||
<StatusBadge
|
||||
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
|
||||
/>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Everything below is detail: useful when asked for, noise on a wall of cameras. */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user