diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx
index bfcce5a..1e799cd 100644
--- a/src/app/camera-card.test.tsx
+++ b/src/app/camera-card.test.tsx
@@ -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();
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();
- 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 () => {
diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx
index fc9c30c..436bae8 100644
--- a/src/app/camera-card.tsx
+++ b/src/app/camera-card.tsx
@@ -88,11 +88,15 @@ function Snapshot({
frame,
paused,
poppedOut,
+ intervalMs,
+ aspect,
}: {
cam: CameraSummary;
frame?: Blob;
paused: boolean;
poppedOut: boolean;
+ intervalMs: number;
+ aspect?: number;
}) {
return (
@@ -103,6 +107,7 @@ function Snapshot({
className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`}
/>
)}
+
{poppedOut ? (
@@ -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 (
{
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"
>
-
- Pop out
+
);
}
@@ -285,18 +293,17 @@ export default function CameraCard({
frame={snapshot.data}
paused={problem !== null || !info.isSuccess}
poppedOut={poppedOut}
+ intervalMs={intervalMs}
+ aspect={aspect}
/>
{cam.name ?? info.data?.model ?? "Unnamed camera"}
-
-
-
-
+
{/* Everything below is detail: useful when asked for, noise on a wall of cameras. */}