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
This commit is contained in:
jlightner 2026-04-04 06:07:23 +00:00
parent ed9aa7a83a
commit 7b111a7ded
7 changed files with 892 additions and 1 deletions

View file

@ -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).

View file

@ -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"
}
]
}

View file

@ -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.

View file

@ -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() {
<Route path="/creator/dashboard" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorDashboard /></Suspense></ProtectedRoute>} />
<Route path="/creator/consent" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ConsentDashboard /></Suspense></ProtectedRoute>} />
<Route path="/creator/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} />
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -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<ChaptersResponse> {
`${BASE}/videos/${encodeURIComponent(videoId)}/chapters`,
);
}
// ── Creator chapter management ───────────────────────────────────────────────
export function fetchCreatorChapters(videoId: string): Promise<ChaptersResponse> {
return request<ChaptersResponse>(
`${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<Chapter> {
return request<Chapter>(
`${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<ChaptersResponse> {
return request<ChaptersResponse>(
`${BASE}/creator/${encodeURIComponent(videoId)}/chapters/reorder`,
{ method: "PUT", body: JSON.stringify({ chapters: order }) },
);
}
export function approveChapters(videoId: string, chapterIds: string[]): Promise<ChaptersResponse> {
return request<ChaptersResponse>(
`${BASE}/creator/${encodeURIComponent(videoId)}/chapters/approve`,
{ method: "POST", body: JSON.stringify({ chapter_ids: chapterIds }) },
);
}

View file

@ -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;
}
}

View file

@ -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<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>
);
}