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:
parent
ed9aa7a83a
commit
7b111a7ded
7 changed files with 892 additions and 1 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
22
.gsd/milestones/M021/slices/S06/tasks/T01-VERIFY.json
Normal file
22
.gsd/milestones/M021/slices/S06/tasks/T01-VERIFY.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
85
.gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md
Normal file
85
.gsd/milestones/M021/slices/S06/tasks/T02-SUMMARY.md
Normal 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.
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 }) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
287
frontend/src/pages/ChapterReview.module.css
Normal file
287
frontend/src/pages/ChapterReview.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
452
frontend/src/pages/ChapterReview.tsx
Normal file
452
frontend/src/pages/ChapterReview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue