From 4fc68cacf1f78a630d2aaaca6701ebeda8d47aa7 Mon Sep 17 00:00:00 2001
From: Michael Mainguy
Date: Sat, 19 Sep 2026 17:16:14 -0500
Subject: [PATCH] Add a Recordings page to browse, play and delete motion clips
- /recordings lists clips newest first with camera, time and size, plays
them in the browser and deletes after a confirmation.
- /api/recordings lists clips; /api/recordings/clip serves one with Range
support so seeking works, and deletes it. A clip is named by folder and
file name, both matched against fixed patterns and resolved inside the
recordings folder, so no request can reach another file.
- Each camera's page links to its own clips.
Co-Authored-By: Claude Opus 5 (1M context)
---
.vrek/log.ndjson | 17 ++++
src/app/api/recordings/clip/route.test.ts | 83 ++++++++++++++++
src/app/api/recordings/clip/route.ts | 64 +++++++++++++
src/app/api/recordings/route.test.ts | 34 +++++++
src/app/api/recordings/route.ts | 12 +++
src/app/cameras/[id]/page.tsx | 13 ++-
src/app/recordings/clip-list.test.tsx | 101 ++++++++++++++++++++
src/app/recordings/clip-list.tsx | 111 ++++++++++++++++++++++
src/app/recordings/page.test.tsx | 68 +++++++++++++
src/app/recordings/page.tsx | 45 +++++++++
src/lib/recordings.test.ts | 74 +++++++++++++++
src/lib/recordings.ts | 64 +++++++++++++
12 files changed, 684 insertions(+), 2 deletions(-)
create mode 100644 src/app/api/recordings/clip/route.test.ts
create mode 100644 src/app/api/recordings/clip/route.ts
create mode 100644 src/app/api/recordings/route.test.ts
create mode 100644 src/app/api/recordings/route.ts
create mode 100644 src/app/recordings/clip-list.test.tsx
create mode 100644 src/app/recordings/clip-list.tsx
create mode 100644 src/app/recordings/page.test.tsx
create mode 100644 src/app/recordings/page.tsx
diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson
index 1d9875b..55fb5fa 100644
--- a/.vrek/log.ndjson
+++ b/.vrek/log.ndjson
@@ -879,3 +879,20 @@
{"id":"evt-fqxcxhh97c1x","type":"edge.added","subject":"ver-wbk0v4t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-wbk0v4t","to":"iss-ws9nb88"},"at":"2026-09-19T22:07:39.760Z","parents":["evt-pts2yq477k34"],"hash":"7f61fcd22e5c71adf48faee46fab3f7f23f551e669c17a95472868216792ea7b"}
{"id":"evt-gr6gmbpx3d72","type":"verification.recorded","subject":"ver-wbk0v4t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User's dev server log on 2026-09-19 17:06: \"[motion] watching 2 camera(s), recording the sub stream\", then for both cameras \"[motion] recording \", MediaMTX \"[recorder] recording … tracks\", and \"[motion] stopped recording \" after the post-roll. Files on disk: .data/recordings/cam--sub-rec/2026-09-19_17-06-*.mp4, 1.2–2.5 MB each; box inspection shows valid fMP4 (ftyp, moov, moof/mdat fragments, avc1)."},"at":"2026-09-19T22:07:39.761Z","parents":["evt-fqxcxhh97c1x"],"hash":"82786785d155aa9499945f9306118813533d1c9bb409d50dc905c034faf65869"}
{"id":"evt-gsf60sb8qp1v","type":"node.status_changed","subject":"iss-ws9nb88","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T22:07:43.955Z","parents":["evt-gr6gmbpx3d72"],"hash":"5f7bb9c68f09bb12cc86d905793efc99f10974c05845cdbfb112429ff33e1094"}
+{"id":"evt-s5abwkxw1wm1","type":"node.created","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A session-checked Recordings page lists clips per camera newest first with time and size, plays them with seeking (Range requests), and deletes after a confirmation; clip ids are matched against fixed patterns and resolved inside the recordings folder, so no request can reach another file; the camera page links to its own clips","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:13:30.531Z","parents":["evt-gsf60sb8qp1v"],"hash":"cabdfc8bd5463c8acb6c382e14e8e1a7d26f15a68a13504cc9069374ce55cd0c"}
+{"id":"evt-n01s9wvxbbp7","type":"edge.added","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-ncyy24g","to":"iss-v677bbd"},"at":"2026-09-19T22:13:30.533Z","parents":["evt-s5abwkxw1wm1"],"hash":"58a3ffe785a9456e3d9773a387a4cb9f1855e601c43608c790c3cbb9e12898b6"}
+{"id":"evt-1hgpgz7w83rh","type":"verification.recorded","subject":"ver-ncyy24g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/recordings.test.ts (listClips, clipFile traversal refusals, deleteClip), src/app/api/recordings/route.test.ts, src/app/api/recordings/clip/route.test.ts (full, range, suffix, 416, traversal), src/app/recordings/clip-list.test.tsx, src/app/recordings/page.test.tsx. Full suite 861 tests pass, 99.67% lines; tsc and eslint clean; next build lists ƒ /recordings, /api/recordings and /api/recordings/clip."},"at":"2026-09-19T22:13:30.534Z","parents":["evt-n01s9wvxbbp7"],"hash":"fbc9a2e411cb1de6b54f186f1a27a332ef6d651adf4eef1f72f7df69ff93a51e"}
+{"id":"evt-m27ze50m7ew2","type":"node.created","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: a motion clip from each camera plays in the browser on /recordings, and deleting one works","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:13:32.598Z","parents":["evt-1hgpgz7w83rh"],"hash":"c5ef657ba791dd92636ecf742dd6248d77f79c49d20ebb65e8a8715f2432f861"}
+{"id":"evt-rgavza6239gj","type":"edge.added","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-aexbfgn","to":"iss-v677bbd"},"at":"2026-09-19T22:13:32.599Z","parents":["evt-m27ze50m7ew2"],"hash":"9a6583fb04bbebc781101bc5a5d3ac538e45048ea2d0c39234968880eddd51ff"}
+{"id":"evt-8jf435p6dswr","type":"verification.recorded","subject":"ver-aexbfgn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User test after restarting the server: open /recordings, play a clip from each camera, delete one"},"at":"2026-09-19T22:13:32.600Z","parents":["evt-rgavza6239gj"],"hash":"4abc418fc138f427492f611f43968dcbbfa9a74f93bc4148c7aacb4abcfca654"}
+{"id":"evt-bmcdhjathg9p","type":"node.status_changed","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T22:13:34.671Z","parents":["evt-8jf435p6dswr"],"hash":"035e51380b988013c77356b04ed50ea7bf55555138e45d969fccaca0e15ca6df"}
+{"id":"evt-cyh6g3kt46hq","type":"node.created","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Recorded G711 audio from camera …af54 does play in the browser, alongside H.264 video","body":"Corrects the caveat in fnd-y7h6pyf, which assumed browsers could not decode G711 inside MP4. On 2026-09-19 the user played motion clips on /recordings and reported that audio worked. So MediaMTX's fMP4 recordings of a G711 (A-law) track are playable in the user's browser as written, with no transcoding, and no audio conversion is needed for playback. Camera …af54 publishes H264 plus G711 on its sub stream; …af2e publishes video only. Untested: whether other browsers behave the same, and whether audio also arrives over the live WebRTC player.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-v677bbd","note":"User's own playback on /recordings: \"this works well and audio seems to work too\""}],"as_of":"2026-09-19"}},"at":"2026-09-19T22:15:57.638Z","parents":["evt-bmcdhjathg9p"],"hash":"2334e4b4fe34f75664847d726b4a3c702108b6ca6d1fdcc9f5ae51c32e5167c4"}
+{"id":"evt-pvzz20apprz9","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"que-ph8eak5"},"at":"2026-09-19T22:15:57.639Z","parents":["evt-cyh6g3kt46hq"],"hash":"6624e96f367c3a826ba21949c49f3814cc8ae61e22b7110fe04f5efc055d4971"}
+{"id":"evt-8nwppxpaj613","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"iss-td8y1cc"},"at":"2026-09-19T22:15:57.640Z","parents":["evt-pvzz20apprz9"],"hash":"c4741584dd29cd005f2c0ecf7f3cdcfe61426abf482ef3664d29d70657b68877"}
+{"id":"evt-k5wqxg4b8t29","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0qyrzm9","to":"gol-sxakryh"},"at":"2026-09-19T22:15:57.641Z","parents":["evt-8nwppxpaj613"],"hash":"3165e4ebf290ddec762097f256959ee4b0b5b6393808d69d2e532d86925b9e54"}
+{"id":"evt-kgedg91yjbht","type":"edge.added","subject":"fnd-0qyrzm9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"supersedes","from":"fnd-0qyrzm9","to":"fnd-y7h6pyf"},"at":"2026-09-19T22:15:57.642Z","parents":["evt-k5wqxg4b8t29"],"hash":"f9a55af6f43f68b49eac03d7b09fe302480032fcd3f66ab9429da2204b9d95d5"}
+{"id":"evt-kxff6phn2yet","type":"node.status_changed","subject":"fnd-y7h6pyf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"current","to":"superseded"},"at":"2026-09-19T22:15:57.643Z","parents":["evt-kgedg91yjbht"],"hash":"bba33d5a64fcf42c2a472d188960e9fc659f97d2a1cae7b53ba1748632607756"}
+{"id":"evt-q6y6kzp0x3rt","type":"node.created","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: motion clips play in the browser on /recordings","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T22:16:00.004Z","parents":["evt-kxff6phn2yet"],"hash":"bacbde6fdba262183500c21401a8349287c68fb122a0ed4e2b075b4f0142bb47"}
+{"id":"evt-xah5c4gbg51b","type":"edge.added","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-60m8ttp","to":"iss-v677bbd"},"at":"2026-09-19T22:16:00.005Z","parents":["evt-q6y6kzp0x3rt"],"hash":"5fc6c47a5ee1452b99e37a9e03b0535035c0b7d7501c54a831a3abc321bdeab5"}
+{"id":"evt-tvjs688dbkvd","type":"verification.recorded","subject":"ver-60m8ttp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User on 2026-09-19: \"this works well and audio seems to work too\" after opening /recordings and playing clips."},"at":"2026-09-19T22:16:00.006Z","parents":["evt-xah5c4gbg51b"],"hash":"3d1b87655feb999a73e54d75a528a625c905420e09c088f434a4034416ccfd63"}
+{"id":"evt-rd8yca1xcevk","type":"node.status_changed","subject":"iss-v677bbd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T22:16:01.993Z","parents":["evt-tvjs688dbkvd"],"hash":"7a448702e6738eb96202a1e82f57a913a64dfb18b154a98b2fcd0246879c0f68"}
diff --git a/src/app/api/recordings/clip/route.test.ts b/src/app/api/recordings/clip/route.test.ts
new file mode 100644
index 0000000..898c5ee
--- /dev/null
+++ b/src/app/api/recordings/clip/route.test.ts
@@ -0,0 +1,83 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
+vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
+
+const { GET, DELETE } = await import("./route");
+
+const ID = "11111111-2222-3333-4444-555555555555";
+const NAME = "2026-09-19_17-06-18-101298.mp4";
+const CLIP = `cam-${ID}-sub-rec/${NAME}`;
+const BODY = "0123456789";
+
+let dir: string;
+beforeEach(async () => {
+ dir = await mkdtemp(path.join(os.tmpdir(), "clip-route-"));
+ vi.stubEnv("RECORDINGS_DIR", dir);
+ const folder = path.join(dir, `cam-${ID}-sub-rec`);
+ await (await import("node:fs/promises")).mkdir(folder, { recursive: true });
+ await writeFile(path.join(folder, NAME), BODY);
+});
+afterEach(() => rm(dir, { recursive: true, force: true }));
+
+const url = (clip = CLIP) => `http://localhost/api/recordings/clip?clip=${encodeURIComponent(clip)}`;
+const get = (clip?: string, headers?: HeadersInit) => GET(new Request(url(clip), { headers }));
+const text = async (res: Response) => Buffer.from(await res.arrayBuffer()).toString();
+
+describe("GET /api/recordings/clip", () => {
+ it("serves the whole clip as seekable video", async () => {
+ const res = await get();
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("video/mp4");
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
+ expect(res.headers.get("Content-Length")).toBe(String(BODY.length));
+ expect(res.headers.get("Cache-Control")).toBe("no-store");
+ expect(await text(res)).toBe(BODY);
+ });
+
+ it("serves a byte range so the player can seek", async () => {
+ const res = await get(CLIP, { range: "bytes=2-5" });
+ expect(res.status).toBe(206);
+ expect(res.headers.get("Content-Range")).toBe(`bytes 2-5/${BODY.length}`);
+ expect(res.headers.get("Content-Length")).toBe("4");
+ expect(await text(res)).toBe("2345");
+ });
+
+ it("serves an open-ended range and a suffix range", async () => {
+ expect(await text(await get(CLIP, { range: "bytes=7-" }))).toBe("789");
+ expect(await text(await get(CLIP, { range: "bytes=-3" }))).toBe("789");
+ });
+
+ it("refuses a range past the end", async () => {
+ const res = await get(CLIP, { range: "bytes=50-60" });
+ expect(res.status).toBe(416);
+ expect(res.headers.get("Content-Range")).toBe(`bytes */${BODY.length}`);
+ });
+
+ it.each([
+ ["a traversal", "../../etc/passwd"],
+ ["a folder that isn't ours", `secrets/${NAME}`],
+ ["a name that isn't a clip", `cam-${ID}-sub-rec/passwd`],
+ ["a clip that doesn't exist", `cam-${ID}-main-rec/${NAME}`],
+ ["nothing", ""],
+ ])("is not found for %s", async (_l, clip) => {
+ expect((await get(clip)).status).toBe(404);
+ });
+});
+
+describe("DELETE /api/recordings/clip", () => {
+ it("deletes the clip", async () => {
+ expect((await DELETE(new Request(url(), { method: "DELETE" }))).status).toBe(204);
+ expect((await get()).status).toBe(404);
+ });
+
+ it.each([["../../etc/passwd"], [""], [`cam-${ID}-sub-rec/2026-01-01_00-00-00-000000.mp4`]])(
+ "is not found for %j",
+ async (clip) => {
+ expect((await DELETE(new Request(url(clip), { method: "DELETE" }))).status).toBe(404);
+ },
+ );
+});
diff --git a/src/app/api/recordings/clip/route.ts b/src/app/api/recordings/clip/route.ts
new file mode 100644
index 0000000..01685d8
--- /dev/null
+++ b/src/app/api/recordings/clip/route.ts
@@ -0,0 +1,64 @@
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import type { ReadableOptions } from "node:stream";
+import { apiAccessDenied } from "@/lib/access";
+import { clipFile, deleteClip } from "@/lib/recordings";
+
+/**
+ * Plays or deletes one motion clip (vrek iss-v677bbd). The ?clip= id is matched against
+ * fixed patterns and resolved inside the recordings folder, so a request can only name a
+ * clip that folder actually holds (pri-m1csgrm).
+ */
+
+function fileFor(request: Request): string | null {
+ return clipFile(new URL(request.url).searchParams.get("clip") ?? "");
+}
+
+/** Node's read stream as a web stream, for one byte range. */
+function bodyFor(file: string, options: ReadableOptions & { start?: number; end?: number }) {
+ return createReadStream(file, options) as unknown as ReadableStream;
+}
+
+export async function GET(request: Request) {
+ const denied = await apiAccessDenied();
+ if (denied) return denied;
+ const file = fileFor(request);
+ const info = file ? await stat(file).catch(() => null) : null;
+ if (!info?.isFile()) return Response.json({ error: "No such clip" }, { status: 404 });
+
+ const headers: Record = {
+ "Content-Type": "video/mp4",
+ "Cache-Control": "no-store",
+ "Accept-Ranges": "bytes",
+ };
+
+ // Seeking in a
-
+
+
+
+ Recordings
+
+
+
diff --git a/src/app/recordings/clip-list.test.tsx b/src/app/recordings/clip-list.test.tsx
new file mode 100644
index 0000000..69d2ee2
--- /dev/null
+++ b/src/app/recordings/clip-list.test.tsx
@@ -0,0 +1,101 @@
+// @vitest-environment jsdom
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Clip } from "@/lib/recordings";
+import { stubFetch } from "../../../test/dom";
+import ClipList from "./clip-list";
+
+const refresh = vi.fn();
+vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
+
+const A = "11111111-2222-3333-4444-555555555555";
+const B = "22ec0000-8b90-11b5-845d-d03bf404af54";
+const clips: Clip[] = [
+ { id: `cam-${A}-sub-rec/2026-09-19_17-06-18-101298.mp4`, cameraId: A, startedAt: "2026-09-19T22:06:18.000Z", bytes: 2_582_503 },
+ { id: `cam-${B}-sub-rec/2026-09-19_08-00-00-000000.mp4`, cameraId: B, startedAt: "2026-09-19T13:00:00.000Z", bytes: 51_200 },
+];
+const names = { [A]: "Porch", [B]: "Garage" };
+const list = (some = clips) => render();
+const clipUrl = (clip: Clip) => `/api/recordings/clip?clip=${encodeURIComponent(clip.id)}`;
+
+beforeEach(() => refresh.mockReset());
+
+describe("ClipList", () => {
+ it("lists each clip with its camera, time and size", () => {
+ list();
+ expect(screen.getByText("Porch")).toBeTruthy();
+ expect(screen.getByText("Garage")).toBeTruthy();
+ expect(screen.getByText("2.5 MB")).toBeTruthy();
+ expect(screen.getByText("50 KB")).toBeTruthy();
+ expect(document.querySelectorAll("time")).toHaveLength(2);
+ });
+
+ it("says so when there is nothing recorded", () => {
+ list([]);
+ expect(screen.getByText(/No clips yet/)).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Play" })).toBeNull();
+ });
+
+ it("names a camera that is no longer in the registry", () => {
+ render();
+ expect(screen.getAllByText("Unknown camera")).toHaveLength(2);
+ });
+
+ it("plays a clip from our own route and closes the player again", () => {
+ list();
+ fireEvent.click(screen.getAllByRole("button", { name: "Play" })[0]);
+ const video = document.querySelector("video")!;
+ expect(video.getAttribute("src")).toBe(clipUrl(clips[0]));
+ expect(screen.getByRole("heading").textContent).toContain("Porch");
+
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(document.querySelector("video")).toBeNull();
+ });
+
+ it("asks before deleting, and can be cancelled", async () => {
+ const fetchMock = stubFetch({});
+ list();
+ fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
+ expect(screen.getByRole("alertdialog", { name: "Delete this clip?" })).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.queryByRole("alertdialog")).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("deletes a clip, closes it if it was playing, and reloads the list", async () => {
+ const fetchMock = stubFetch({
+ [`DELETE ${clipUrl(clips[0]).split("?")[0]}`]: () => new Response(null, { status: 204 }),
+ });
+ list();
+ fireEvent.click(screen.getAllByRole("button", { name: "Play" })[0]);
+ fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
+ fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
+ expect(String(fetchMock.mock.calls[0][0])).toBe(clipUrl(clips[0]));
+ expect(document.querySelector("video")).toBeNull();
+ });
+
+ it("says when a delete fails, and keeps the clip listed", async () => {
+ stubFetch({
+ [`DELETE ${clipUrl(clips[0]).split("?")[0]}`]: () => new Response(null, { status: 500 }),
+ });
+ list();
+ fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
+ fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
+ expect((await screen.findByRole("alert")).textContent).toContain("could not be deleted");
+ expect(refresh).not.toHaveBeenCalled();
+ expect(screen.getByText("Porch")).toBeTruthy();
+ });
+
+ it("treats a clip that is already gone as deleted", async () => {
+ stubFetch({
+ [`DELETE ${clipUrl(clips[0]).split("?")[0]}`]: () => new Response(null, { status: 404 }),
+ });
+ list();
+ fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
+ fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
+ });
+});
diff --git a/src/app/recordings/clip-list.tsx b/src/app/recordings/clip-list.tsx
new file mode 100644
index 0000000..c07b600
--- /dev/null
+++ b/src/app/recordings/clip-list.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { CircleAlert, Film, Play, Trash2, X } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import type { Clip } from "@/lib/recordings";
+
+/** "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 }) {
+ const router = useRouter();
+ const [playing, setPlaying] = useState(null);
+ const [confirming, setConfirming] = useState(null);
+ const [error, setError] = useState(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;
+ }
+ if (playing?.id === clip.id) setPlaying(null);
+ router.refresh();
+ }
+
+ if (clips.length === 0) {
+ return (
+
+ No clips yet. One is recorded whenever a camera reports motion.
+