From 7b111a7ded25aa3aca5a6f32d8001ec4f7e236eb Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:07:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20ChapterReview=20page=20with=20W?= =?UTF-8?q?aveSurfer=20waveform=20(draggable/resi=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/pages/ChapterReview.tsx" - "frontend/src/pages/ChapterReview.module.css" - "frontend/src/api/videos.ts" - "frontend/src/App.tsx" GSD-Task: S06/T02 --- .gsd/milestones/M021/slices/S06/S06-PLAN.md | 2 +- .../M021/slices/S06/tasks/T01-VERIFY.json | 22 + .../M021/slices/S06/tasks/T02-SUMMARY.md | 85 ++++ frontend/src/App.tsx | 2 + frontend/src/api/videos.ts | 43 ++ frontend/src/pages/ChapterReview.module.css | 287 +++++++++++ frontend/src/pages/ChapterReview.tsx | 452 ++++++++++++++++++ 7 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 .gsd/milestones/M021/slices/S06/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md create mode 100644 frontend/src/pages/ChapterReview.module.css create mode 100644 frontend/src/pages/ChapterReview.tsx diff --git a/.gsd/milestones/M021/slices/S06/S06-PLAN.md b/.gsd/milestones/M021/slices/S06/S06-PLAN.md index ee0cace..f59a3bd 100644 --- a/.gsd/milestones/M021/slices/S06/S06-PLAN.md +++ b/.gsd/milestones/M021/slices/S06/S06-PLAN.md @@ -23,7 +23,7 @@ Steps: - Estimate: 1.5h - Files: backend/models.py, backend/schemas.py, alembic/versions/020_add_chapter_status_and_sort_order.py, backend/routers/creator_chapters.py, backend/routers/videos.py, backend/main.py - Verify: python -c "from models import KeyMoment, ChapterStatus; print(ChapterStatus.draft)" && python -c "from schemas import ChapterUpdate, ChapterReorderRequest, ChapterBulkApproveRequest; print('schemas OK')" && grep -q 'creator_chapters' backend/main.py && test -f alembic/versions/020_add_chapter_status_and_sort_order.py -- [ ] **T02: Build Chapter Review page with editable waveform regions and chapter list** — Create the frontend Chapter Review page at `/creator/chapters/:videoId`. This page lets a creator view their video's chapters on an interactive waveform (drag to resize regions) and in a sortable list below (inline rename, status badges, approve/hide actions, bulk approve). +- [x] **T02: Built ChapterReview page with WaveSurfer waveform (draggable/resizable regions), inline chapter list editing, reorder arrows, status cycling, and bulk approve actions** — Create the frontend Chapter Review page at `/creator/chapters/:videoId`. This page lets a creator view their video's chapters on an interactive waveform (drag to resize regions) and in a sortable list below (inline rename, status badges, approve/hide actions, bulk approve). The page reuses the WaveSurfer + RegionsPlugin pattern from `frontend/src/components/AudioWaveform.tsx` but with `drag: true` and `resize: true` on regions. Region update events sync back to component state. The chapter list uses simple up/down arrow buttons for reorder (no DnD library needed for 5-15 items). diff --git a/.gsd/milestones/M021/slices/S06/tasks/T01-VERIFY.json b/.gsd/milestones/M021/slices/S06/tasks/T01-VERIFY.json new file mode 100644 index 0000000..3ce8670 --- /dev/null +++ b/.gsd/milestones/M021/slices/S06/tasks/T01-VERIFY.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M021/S06/T01", + "timestamp": 1775282629271, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "grep -q 'creator_chapters' backend/main.py", + "exitCode": 0, + "durationMs": 9, + "verdict": "pass" + }, + { + "command": "test -f alembic/versions/020_add_chapter_status_and_sort_order.py", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md b/.gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..a82222a --- /dev/null +++ b/.gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md @@ -0,0 +1,85 @@ +--- +id: T02 +parent: S06 +milestone: M021 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/pages/ChapterReview.tsx", "frontend/src/pages/ChapterReview.module.css", "frontend/src/api/videos.ts", "frontend/src/App.tsx"] +key_decisions: ["Region drag/resize persists via fire-and-forget updateChapter with error logging", "Status cycling: draft → approved → hidden → draft", "Optimistic reorder with server reconciliation"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "TypeScript compiles cleanly (npx tsc --noEmit exits 0), all 3 expected output files exist, updateChapter confirmed in API module, all 4 new API functions exported." +completed_at: 2026-04-04T06:07:20.015Z +blocker_discovered: false +--- + +# T02: Built ChapterReview page with WaveSurfer waveform (draggable/resizable regions), inline chapter list editing, reorder arrows, status cycling, and bulk approve actions + +> Built ChapterReview page with WaveSurfer waveform (draggable/resizable regions), inline chapter list editing, reorder arrows, status cycling, and bulk approve actions + +## What Happened +--- +id: T02 +parent: S06 +milestone: M021 +key_files: + - frontend/src/pages/ChapterReview.tsx + - frontend/src/pages/ChapterReview.module.css + - frontend/src/api/videos.ts + - frontend/src/App.tsx +key_decisions: + - Region drag/resize persists via fire-and-forget updateChapter with error logging + - Status cycling: draft → approved → hidden → draft + - Optimistic reorder with server reconciliation +duration: "" +verification_result: passed +completed_at: 2026-04-04T06:07:20.015Z +blocker_discovered: false +--- + +# T02: Built ChapterReview page with WaveSurfer waveform (draggable/resizable regions), inline chapter list editing, reorder arrows, status cycling, and bulk approve actions + +**Built ChapterReview page with WaveSurfer waveform (draggable/resizable regions), inline chapter list editing, reorder arrows, status cycling, and bulk approve actions** + +## What Happened + +Extended the Chapter interface in videos.ts with chapter_status and sort_order fields, plus 4 new API functions (fetchCreatorChapters, updateChapter, reorderChapters, approveChapters). Created ChapterReview.tsx with WaveSurfer waveform (drag/resize regions), region-updated sync to API, inline title editing, up/down reorder, status cycle button, checkbox bulk approve, and loading/error/empty states. Created ChapterReview.module.css following CreatorDashboard design patterns. Added lazy-loaded ProtectedRoute at /creator/chapters/:videoId in App.tsx. + +## Verification + +TypeScript compiles cleanly (npx tsc --noEmit exits 0), all 3 expected output files exist, updateChapter confirmed in API module, all 4 new API functions exported. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 8000ms | +| 2 | `test -f src/pages/ChapterReview.tsx` | 0 | ✅ pass | 10ms | +| 3 | `test -f src/pages/ChapterReview.module.css` | 0 | ✅ pass | 10ms | +| 4 | `grep -q 'updateChapter' src/api/videos.ts` | 0 | ✅ pass | 10ms | + + +## Deviations + +Added route registration in App.tsx and region color per status — minor enhancements beyond explicit plan steps. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/pages/ChapterReview.tsx` +- `frontend/src/pages/ChapterReview.module.css` +- `frontend/src/api/videos.ts` +- `frontend/src/App.tsx` + + +## Deviations +Added route registration in App.tsx and region color per status — minor enhancements beyond explicit plan steps. + +## Known Issues +None. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 10c7545..248d533 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard")); const WatchPage = React.lazy(() => import("./pages/WatchPage")); const AdminUsers = React.lazy(() => import("./pages/AdminUsers")); const ChatPage = React.lazy(() => import("./pages/ChatPage")); +const ChapterReview = React.lazy(() => import("./pages/ChapterReview")); import AdminDropdown from "./components/AdminDropdown"; import ImpersonationBanner from "./components/ImpersonationBanner"; import AppFooter from "./components/AppFooter"; @@ -198,6 +199,7 @@ function AppShell() { }>} /> }>} /> }>} /> + }>} /> {/* Fallback */} } /> diff --git a/frontend/src/api/videos.ts b/frontend/src/api/videos.ts index 6ec3416..8888a6b 100644 --- a/frontend/src/api/videos.ts +++ b/frontend/src/api/videos.ts @@ -53,6 +53,8 @@ export interface Chapter { start_time: number; end_time: number; content_type: string; + chapter_status: string; + sort_order: number; } export interface ChaptersResponse { @@ -65,3 +67,44 @@ export function fetchChapters(videoId: string): Promise { `${BASE}/videos/${encodeURIComponent(videoId)}/chapters`, ); } + +// ── Creator chapter management ─────────────────────────────────────────────── + +export function fetchCreatorChapters(videoId: string): Promise { + return request( + `${BASE}/creator/${encodeURIComponent(videoId)}/chapters`, + ); +} + +export interface ChapterPatch { + title?: string; + start_time?: number; + end_time?: number; + chapter_status?: string; +} + +export function updateChapter(chapterId: string, patch: ChapterPatch): Promise { + return request( + `${BASE}/creator/chapters/${encodeURIComponent(chapterId)}`, + { method: "PATCH", body: JSON.stringify(patch) }, + ); +} + +export interface ReorderItem { + id: string; + sort_order: number; +} + +export function reorderChapters(videoId: string, order: ReorderItem[]): Promise { + return request( + `${BASE}/creator/${encodeURIComponent(videoId)}/chapters/reorder`, + { method: "PUT", body: JSON.stringify({ chapters: order }) }, + ); +} + +export function approveChapters(videoId: string, chapterIds: string[]): Promise { + return request( + `${BASE}/creator/${encodeURIComponent(videoId)}/chapters/approve`, + { method: "POST", body: JSON.stringify({ chapter_ids: chapterIds }) }, + ); +} diff --git a/frontend/src/pages/ChapterReview.module.css b/frontend/src/pages/ChapterReview.module.css new file mode 100644 index 0000000..c9529f5 --- /dev/null +++ b/frontend/src/pages/ChapterReview.module.css @@ -0,0 +1,287 @@ +/* ── Layout — reuses creator sidebar pattern ──────────────────────────────── */ + +.layout { + display: flex; + gap: 0; + min-height: 60vh; +} + +.content { + flex: 1; + min-width: 0; + padding: 2rem; +} + +.pageTitle { + margin: 0 0 0.25rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.subtitle { + margin: 0 0 1.5rem; + font-size: 0.875rem; + color: var(--color-text-muted); +} + +/* ── Waveform ─────────────────────────────────────────────────────────────── */ + +.waveformWrap { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.waveformLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 0.5rem; +} + +.waveformCanvas { + min-height: 128px; +} + +/* ── Bulk actions bar ─────────────────────────────────────────────────────── */ + +.bulkBar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.bulkBtn { + padding: 0.375rem 0.875rem; + border-radius: 6px; + font-size: 0.8125rem; + font-weight: 600; + border: 1px solid var(--color-border); + background: var(--color-bg-surface); + color: var(--color-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.bulkBtn:hover { + background: var(--color-accent-subtle); + color: var(--color-accent); +} + +.bulkBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.bulkBtnPrimary { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} + +.bulkBtnPrimary:hover { + opacity: 0.9; +} + +.selectedCount { + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +/* ── Chapter list ─────────────────────────────────────────────────────────── */ + +.chapterList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.chapterRow { + display: flex; + align-items: center; + gap: 0.625rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 0.625rem 0.875rem; + transition: border-color 0.15s; +} + +.chapterRow:hover { + border-color: var(--color-accent); +} + +.chapterCheckbox { + flex-shrink: 0; + width: 16px; + height: 16px; + accent-color: var(--color-accent); +} + +.chapterIndex { + flex-shrink: 0; + width: 1.5rem; + text-align: center; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.chapterTitle { + flex: 1; + min-width: 0; + border: 1px solid transparent; + background: transparent; + color: var(--color-text-primary); + font-size: 0.875rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: border-color 0.15s; +} + +.chapterTitle:focus { + border-color: var(--color-accent); + outline: none; + background: var(--color-bg-surface-hover); +} + +.chapterTimes { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* ── Status badge ─────────────────────────────────────────────────────────── */ + +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + white-space: nowrap; + flex-shrink: 0; +} + +.badgeDraft { + background: var(--color-badge-pending-bg); + color: var(--color-badge-pending-text); +} + +.badgeApproved { + background: var(--color-badge-approved-bg); + color: var(--color-badge-approved-text); +} + +.badgeHidden { + background: var(--color-badge-rejected-bg); + color: var(--color-badge-rejected-text); +} + +/* ── Row action buttons ───────────────────────────────────────────────────── */ + +.rowActions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +.iconBtn { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} + +.iconBtn:hover { + color: var(--color-accent); + background: var(--color-accent-subtle); +} + +.iconBtn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.iconBtn svg { + width: 16px; + height: 16px; +} + +/* ── States ────────────────────────────────────────────────────────────────── */ + +.loadingState { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.emptyState { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-muted); +} + +.emptyState h2 { + font-size: 1.125rem; + color: var(--color-text-secondary); + margin: 0 0 0.5rem; +} + +.emptyState p { + font-size: 0.875rem; + margin: 0; +} + +.errorState { + background: var(--color-error-bg, rgba(220, 38, 38, 0.1)); + color: var(--color-error, #ef4444); + padding: 1rem; + border-radius: 8px; + font-size: 0.875rem; +} + +/* ── Responsive ────────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .layout { + flex-direction: column; + } + + .content { + padding: 1.25rem; + } + + .chapterRow { + flex-wrap: wrap; + } + + .chapterTitle { + width: 100%; + order: 10; + } + + .bulkBar { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/src/pages/ChapterReview.tsx b/frontend/src/pages/ChapterReview.tsx new file mode 100644 index 0000000..f583dbe --- /dev/null +++ b/frontend/src/pages/ChapterReview.tsx @@ -0,0 +1,452 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import WaveSurfer from "wavesurfer.js"; +import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js"; +import { SidebarNav } from "./CreatorDashboard"; +import { ApiError } from "../api/client"; +import { + fetchCreatorChapters, + fetchVideo, + updateChapter, + reorderChapters, + approveChapters, + type Chapter, + type ChapterPatch, + type VideoDetail, +} from "../api/videos"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import styles from "./ChapterReview.module.css"; + +/* ── Helpers ────────────────────────────────────────────────────────────────── */ + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function statusBadgeClass(status: string): string { + switch (status) { + case "approved": + return styles.badgeApproved; + case "hidden": + return styles.badgeHidden; + default: + return styles.badgeDraft; + } +} + +/* ── Region color per status ───────────────────────────────────────────────── */ + +function regionColor(status: string): string { + switch (status) { + case "approved": + return "rgba(34, 197, 94, 0.15)"; + case "hidden": + return "rgba(239, 68, 68, 0.10)"; + default: + return "rgba(0, 255, 209, 0.12)"; + } +} + +/* ── Main component ────────────────────────────────────────────────────────── */ + +export default function ChapterReview() { + const { videoId } = useParams<{ videoId: string }>(); + useDocumentTitle("Chapter Review"); + + const [chapters, setChapters] = useState([]); + const [video, setVideo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [saving, setSaving] = useState(false); + + // WaveSurfer refs + const waveContainerRef = useRef(null); + const wsRef = useRef(null); + const regionsRef = useRef(null); + const audioRef = useRef(null); + + // Stable ref for chapters so region callbacks see latest state + const chaptersRef = useRef(chapters); + chaptersRef.current = chapters; + + /* ── Fetch data ──────────────────────────────────────────────────────────── */ + + useEffect(() => { + if (!videoId) return; + let cancelled = false; + setLoading(true); + setError(null); + + Promise.all([fetchCreatorChapters(videoId), fetchVideo(videoId)]) + .then(([chapRes, vid]) => { + if (cancelled) return; + setChapters(chapRes.chapters); + setVideo(vid); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof ApiError ? err.detail : "Failed to load chapters"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [videoId]); + + /* ── WaveSurfer init ─────────────────────────────────────────────────────── */ + + useEffect(() => { + const container = waveContainerRef.current; + const audio = audioRef.current; + if (!container || !audio || chapters.length === 0) return; + + // Destroy any previous instance + if (wsRef.current) { + wsRef.current.destroy(); + wsRef.current = null; + regionsRef.current = null; + } + + const regions = RegionsPlugin.create(); + regionsRef.current = regions; + + const ws = WaveSurfer.create({ + container, + media: audio, + height: 128, + waveColor: "rgba(34, 211, 238, 0.4)", + progressColor: "rgba(34, 211, 238, 0.8)", + cursorColor: "#22d3ee", + barWidth: 2, + barGap: 1, + barRadius: 2, + backend: "MediaElement", + plugins: [regions], + }); + + wsRef.current = ws; + + ws.on("ready", () => { + for (const ch of chaptersRef.current) { + regions.addRegion({ + id: ch.id, + start: ch.start_time, + end: ch.end_time, + content: ch.title, + color: regionColor(ch.chapter_status), + drag: true, + resize: true, + }); + } + }); + + // Sync region drag/resize back to chapter state and API + regions.on("region-updated", (region: any) => { + const chId = region.id as string; + const newStart = region.start as number; + const newEnd = region.end as number; + + setChapters((prev) => + prev.map((c) => + c.id === chId ? { ...c, start_time: newStart, end_time: newEnd } : c, + ), + ); + + // Persist to backend (fire-and-forget with error logging) + updateChapter(chId, { start_time: newStart, end_time: newEnd }).catch((err) => + console.error("Failed to save region update:", err), + ); + }); + + return () => { + ws.destroy(); + wsRef.current = null; + regionsRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [video, chapters.length > 0]); // re-init when video loads or chapters first arrive + + /* ── Chapter actions ─────────────────────────────────────────────────────── */ + + const handleTitleChange = useCallback( + (chapterId: string, newTitle: string) => { + setChapters((prev) => + prev.map((c) => (c.id === chapterId ? { ...c, title: newTitle } : c)), + ); + }, + [], + ); + + const handleTitleBlur = useCallback( + (chapterId: string, title: string) => { + updateChapter(chapterId, { title }).catch((err) => + console.error("Failed to save title:", err), + ); + // Update region label + const regions = regionsRef.current; + if (regions) { + const allRegions = regions.getRegions(); + const r = allRegions.find((rg: any) => rg.id === chapterId); + if (r) { + r.setOptions({ content: title }); + } + } + }, + [], + ); + + const handleStatusToggle = useCallback( + async (chapterId: string, currentStatus: string) => { + const next: Record = { + draft: "approved", + approved: "hidden", + hidden: "draft", + }; + const newStatus = next[currentStatus] || "draft"; + try { + const updated = await updateChapter(chapterId, { chapter_status: newStatus }); + setChapters((prev) => + prev.map((c) => (c.id === chapterId ? { ...c, ...updated } : c)), + ); + // Update region color + const regions = regionsRef.current; + if (regions) { + const allRegions = regions.getRegions(); + const r = allRegions.find((rg: any) => rg.id === chapterId); + if (r) r.setOptions({ color: regionColor(newStatus) }); + } + } catch (err) { + console.error("Failed to toggle status:", err); + } + }, + [], + ); + + const handleMoveUp = useCallback( + async (index: number) => { + if (!videoId || index <= 0) return; + const newList = [...chapters]; + [newList[index - 1], newList[index]] = [newList[index], newList[index - 1]]; + const order = newList.map((c, i) => ({ id: c.id, sort_order: i })); + setChapters(newList); + try { + const res = await reorderChapters(videoId, order); + setChapters(res.chapters); + } catch (err) { + console.error("Failed to reorder:", err); + } + }, + [chapters, videoId], + ); + + const handleMoveDown = useCallback( + async (index: number) => { + if (!videoId || index >= chapters.length - 1) return; + const newList = [...chapters]; + [newList[index], newList[index + 1]] = [newList[index + 1], newList[index]]; + const order = newList.map((c, i) => ({ id: c.id, sort_order: i })); + setChapters(newList); + try { + const res = await reorderChapters(videoId, order); + setChapters(res.chapters); + } catch (err) { + console.error("Failed to reorder:", err); + } + }, + [chapters, videoId], + ); + + /* ── Bulk actions ────────────────────────────────────────────────────────── */ + + const toggleSelect = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const toggleSelectAll = useCallback(() => { + setSelected((prev) => + prev.size === chapters.length + ? new Set() + : new Set(chapters.map((c) => c.id)), + ); + }, [chapters]); + + const handleApproveSelected = useCallback(async () => { + if (!videoId || selected.size === 0) return; + setSaving(true); + try { + const res = await approveChapters(videoId, Array.from(selected)); + setChapters(res.chapters); + setSelected(new Set()); + } catch (err) { + console.error("Failed to approve:", err); + } finally { + setSaving(false); + } + }, [videoId, selected]); + + const handleApproveAll = useCallback(async () => { + if (!videoId) return; + setSaving(true); + try { + const allIds = chapters.map((c) => c.id); + const res = await approveChapters(videoId, allIds); + setChapters(res.chapters); + setSelected(new Set()); + } catch (err) { + console.error("Failed to approve all:", err); + } finally { + setSaving(false); + } + }, [videoId, chapters]); + + /* ── Audio source ────────────────────────────────────────────────────────── */ + + const audioSrc = video?.video_url || (video ? `/api/v1/videos/${video.id}/stream` : ""); + + /* ── Render ──────────────────────────────────────────────────────────────── */ + + return ( +
+ +
+

Chapter Review

+

+ {video ? video.filename : "Loading…"} + {chapters.length > 0 && ` · ${chapters.length} chapters`} +

+ + {loading &&
Loading chapters…
} + + {!loading && error &&
{error}
} + + {!loading && !error && chapters.length === 0 && ( +
+

No Chapters

+

This video doesn't have any auto-detected chapters yet.

+
+ )} + + {!loading && !error && chapters.length > 0 && ( + <> + {/* ── Waveform ─────────────────────────────────────────── */} +
+

+ Drag region edges to adjust chapter boundaries +

+
+
+ ); +}