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
|
- 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
|
- 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
|
- 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).
|
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 WatchPage = React.lazy(() => import("./pages/WatchPage"));
|
||||||
const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
|
const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
|
||||||
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
||||||
|
const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import ImpersonationBanner from "./components/ImpersonationBanner";
|
import ImpersonationBanner from "./components/ImpersonationBanner";
|
||||||
import AppFooter from "./components/AppFooter";
|
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/dashboard" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorDashboard /></Suspense></ProtectedRoute>} />
|
||||||
<Route path="/creator/consent" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ConsentDashboard /></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/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} />
|
||||||
|
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ export interface Chapter {
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time: number;
|
end_time: number;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
|
chapter_status: string;
|
||||||
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChaptersResponse {
|
export interface ChaptersResponse {
|
||||||
|
|
@ -65,3 +67,44 @@ export function fetchChapters(videoId: string): Promise<ChaptersResponse> {
|
||||||
`${BASE}/videos/${encodeURIComponent(videoId)}/chapters`,
|
`${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