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>
104 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|