- "frontend/src/pages/ChapterReview.tsx" - "frontend/src/pages/ChapterReview.module.css" - "frontend/src/api/videos.ts" - "frontend/src/App.tsx" GSD-Task: S06/T02
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|