chrysopedia/frontend/src/pages/ChapterReview.tsx
jlightner fa972a4fbc feat: Built ChapterReview page with WaveSurfer waveform (draggable/resi…
- "frontend/src/pages/ChapterReview.tsx"
- "frontend/src/pages/ChapterReview.module.css"
- "frontend/src/api/videos.ts"
- "frontend/src/App.tsx"

GSD-Task: S06/T02
2026-04-04 06:07:23 +00:00

452 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Chapter[]>([]);
const [video, setVideo] = useState<VideoDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
// WaveSurfer refs
const waveContainerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WaveSurfer | null>(null);
const regionsRef = useRef<RegionsPlugin | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Stable ref for chapters so region callbacks see latest state
const chaptersRef = useRef<Chapter[]>(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<string, string> = {
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 (
<div className={styles.layout}>
<SidebarNav />
<div className={styles.content}>
<h1 className={styles.pageTitle}>Chapter Review</h1>
<p className={styles.subtitle}>
{video ? video.filename : "Loading…"}
{chapters.length > 0 && ` · ${chapters.length} chapters`}
</p>
{loading && <div className={styles.loadingState}>Loading chapters</div>}
{!loading && error && <div className={styles.errorState}>{error}</div>}
{!loading && !error && chapters.length === 0 && (
<div className={styles.emptyState}>
<h2>No Chapters</h2>
<p>This video doesn't have any auto-detected chapters yet.</p>
</div>
)}
{!loading && !error && chapters.length > 0 && (
<>
{/* ── Waveform ─────────────────────────────────────────── */}
<div className={styles.waveformWrap}>
<p className={styles.waveformLabel}>
Drag region edges to adjust chapter boundaries
</p>
<audio ref={audioRef} src={audioSrc} preload="metadata" style={{ display: "none" }} />
<div ref={waveContainerRef} className={styles.waveformCanvas} />
</div>
{/* ── Bulk action bar ──────────────────────────────────── */}
<div className={styles.bulkBar}>
<button className={styles.bulkBtn} onClick={toggleSelectAll} type="button">
{selected.size === chapters.length ? "Deselect All" : "Select All"}
</button>
<button
className={`${styles.bulkBtn} ${styles.bulkBtnPrimary}`}
onClick={handleApproveSelected}
disabled={selected.size === 0 || saving}
type="button"
>
Approve Selected ({selected.size})
</button>
<button
className={styles.bulkBtn}
onClick={handleApproveAll}
disabled={saving}
type="button"
>
Approve All
</button>
{selected.size > 0 && (
<span className={styles.selectedCount}>
{selected.size} of {chapters.length} selected
</span>
)}
</div>
{/* ── Chapter list ─────────────────────────────────────── */}
<div className={styles.chapterList}>
{chapters.map((ch, idx) => (
<div key={ch.id} className={styles.chapterRow}>
<input
type="checkbox"
className={styles.chapterCheckbox}
checked={selected.has(ch.id)}
onChange={() => toggleSelect(ch.id)}
/>
<span className={styles.chapterIndex}>{idx + 1}</span>
<input
type="text"
className={styles.chapterTitle}
value={ch.title}
onChange={(e) => handleTitleChange(ch.id, e.target.value)}
onBlur={(e) => handleTitleBlur(ch.id, e.target.value)}
/>
<span className={styles.chapterTimes}>
{formatTime(ch.start_time)} {formatTime(ch.end_time)}
</span>
<span className={`${styles.badge} ${statusBadgeClass(ch.chapter_status)}`}>
{ch.chapter_status}
</span>
<div className={styles.rowActions}>
{/* Move up */}
<button
className={styles.iconBtn}
onClick={() => handleMoveUp(idx)}
disabled={idx === 0}
title="Move up"
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
{/* Move down */}
<button
className={styles.iconBtn}
onClick={() => handleMoveDown(idx)}
disabled={idx === chapters.length - 1}
title="Move down"
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{/* Status cycle: draft → approved → hidden → draft */}
<button
className={styles.iconBtn}
onClick={() => handleStatusToggle(ch.id, ch.chapter_status)}
title={`Status: ${ch.chapter_status} (click to cycle)`}
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{ch.chapter_status === "approved" ? (
<><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></>
) : ch.chapter_status === "hidden" ? (
<><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" /><line x1="1" y1="1" x2="23" y2="23" /></>
) : (
<circle cx="12" cy="12" r="10" />
)}
</svg>
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
);
}