cameras/src/app/recordings/clip-list.tsx
Michael Mainguy 0acd69b9ca Play recordings in a pop-out window, like the live view
Play now opens /recordings/play?clip=… in a window of its own: the video
on black, fitted to the window, with a fading overlay naming the camera
and the time. One window is reused for whichever clip is opened next, and
it stays an ordinary link so a blocked popup still opens a tab. The page
checks the session and resolves the clip inside the recordings folder.

Also add DOM cleanup to three test files that had none: renders piled up
in the document and tests read each other's markup, which is what made
the new test pass or fail depending on its position in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 05:38:51 -05:00

104 lines
3.8 KiB
TypeScript

"use client";
import { CircleAlert, Film, Play, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { Clip } from "@/lib/recordings";
import { clipPopoutUrl, CLIP_WINDOW_NAME, openClipPopout } from "../popout";
/** "2.4 MB" */
function sizeLabel(bytes: number): string {
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
const clipUrl = (id: string) => `/api/recordings/clip?clip=${encodeURIComponent(id)}`;
export default function ClipList({ clips, names }: { clips: Clip[]; names: Record<string, string> }) {
const router = useRouter();
const [confirming, setConfirming] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function remove(clip: Clip) {
setConfirming(null);
setError(null);
const res = await fetch(clipUrl(clip.id), { method: "DELETE" }).catch(() => null);
if (!res || (!res.ok && res.status !== 404)) {
setError("That clip could not be deleted.");
return;
}
router.refresh();
}
if (clips.length === 0) {
return (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
No clips yet. One is recorded whenever a camera reports motion.
</p>
);
}
return (
<div className="flex flex-col gap-4">
{error && (
<p role="alert" className="inline-flex items-center gap-1.5 text-sm text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{error}
</p>
)}
<ul className="flex flex-col gap-2">
{clips.map((clip) => (
<li
key={clip.id}
className="flex flex-wrap items-center justify-between gap-2 rounded border border-zinc-200 px-3 py-2 text-sm dark:border-zinc-800"
>
<span className="inline-flex items-center gap-2">
<Film aria-hidden className="size-4 shrink-0 text-zinc-500" />
<span>
<span className="font-medium">{names[clip.cameraId] ?? "Unknown camera"}</span>{" "}
<time dateTime={clip.startedAt}>{new Date(clip.startedAt).toLocaleString()}</time>
</span>
<span className="text-zinc-500">{sizeLabel(clip.bytes)}</span>
</span>
{confirming === clip.id ? (
<span role="alertdialog" aria-label="Delete this clip?" className="flex items-center gap-2">
<span>Delete this clip?</span>
<button
onClick={() => remove(clip)}
className="inline-flex items-center gap-1 rounded bg-red-600 px-2 py-1 text-white"
>
<Trash2 aria-hidden className="size-4" />
Delete
</button>
<button onClick={() => setConfirming(null)} className="underline">
Cancel
</button>
</span>
) : (
<span className="flex items-center gap-3">
{/* A link, so it still opens a tab if popups are blocked. */}
<a
href={clipPopoutUrl(clip.id)}
target={CLIP_WINDOW_NAME}
onClick={(e) => {
if (openClipPopout(clip.id)) e.preventDefault();
}}
className="inline-flex items-center gap-1 underline"
>
<Play aria-hidden className="size-4" />
Play
</a>
<button onClick={() => setConfirming(clip.id)} className="inline-flex items-center gap-1 underline">
<Trash2 aria-hidden className="size-4" />
Delete
</button>
</span>
)}
</li>
))}
</ul>
</div>
);
}