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

); }