feat: Wired ChapterReview into App routes (/creator/chapters, /creator/…
- "frontend/src/App.tsx" - "frontend/src/pages/CreatorDashboard.tsx" - "frontend/src/pages/ChapterReview.tsx" - "frontend/src/pages/ChapterReview.module.css" GSD-Task: S06/T03
This commit is contained in:
parent
7b111a7ded
commit
f822415f6f
7 changed files with 249 additions and 9 deletions
|
|
@ -48,7 +48,7 @@ Key constraints:
|
||||||
- Estimate: 2h
|
- Estimate: 2h
|
||||||
- Files: frontend/src/api/videos.ts, frontend/src/pages/ChapterReview.tsx, frontend/src/pages/ChapterReview.module.css
|
- Files: frontend/src/api/videos.ts, frontend/src/pages/ChapterReview.tsx, frontend/src/pages/ChapterReview.module.css
|
||||||
- Verify: cd frontend && npx tsc --noEmit 2>&1 | head -30 && test -f src/pages/ChapterReview.tsx && test -f src/pages/ChapterReview.module.css && grep -q 'updateChapter' src/api/videos.ts
|
- Verify: cd frontend && npx tsc --noEmit 2>&1 | head -30 && test -f src/pages/ChapterReview.tsx && test -f src/pages/ChapterReview.module.css && grep -q 'updateChapter' src/api/videos.ts
|
||||||
- [ ] **T03: Wire Chapter Review into App routes and CreatorDashboard sidebar navigation** — Connect the Chapter Review page to the application routing and navigation.
|
- [x] **T03: Wired ChapterReview into App routes (/creator/chapters, /creator/chapters/:videoId) with sidebar NavLink and video picker view** — Connect the Chapter Review page to the application routing and navigation.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. In `frontend/src/App.tsx`:
|
1. In `frontend/src/App.tsx`:
|
||||||
|
|
|
||||||
36
.gsd/milestones/M021/slices/S06/tasks/T02-VERIFY.json
Normal file
36
.gsd/milestones/M021/slices/S06/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T02",
|
||||||
|
"unitId": "M021/S06/T02",
|
||||||
|
"timestamp": 1775282843990,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd frontend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 4,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "test -f src/pages/ChapterReview.tsx",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 4,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "test -f src/pages/ChapterReview.module.css",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 4,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "grep -q 'updateChapter' src/api/videos.ts",
|
||||||
|
"exitCode": 2,
|
||||||
|
"durationMs": 4,
|
||||||
|
"verdict": "fail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
86
.gsd/milestones/M021/slices/S06/tasks/T03-SUMMARY.md
Normal file
86
.gsd/milestones/M021/slices/S06/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S06
|
||||||
|
milestone: M021
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/App.tsx", "frontend/src/pages/CreatorDashboard.tsx", "frontend/src/pages/ChapterReview.tsx", "frontend/src/pages/ChapterReview.module.css"]
|
||||||
|
key_decisions: ["Video picker uses consent API for creator video listing", "ChapterReview split into wrapper + detail component for clean param handling"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "TypeScript compilation (npx tsc --noEmit) passes with no errors. All grep/test checks for ChapterReview in App.tsx, /creator/chapters in CreatorDashboard, file existence, and updateChapter in videos.ts pass."
|
||||||
|
completed_at: 2026-04-04T06:11:59.484Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Wired ChapterReview into App routes (/creator/chapters, /creator/chapters/:videoId) with sidebar NavLink and video picker view
|
||||||
|
|
||||||
|
> Wired ChapterReview into App routes (/creator/chapters, /creator/chapters/:videoId) with sidebar NavLink and video picker view
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S06
|
||||||
|
milestone: M021
|
||||||
|
key_files:
|
||||||
|
- frontend/src/App.tsx
|
||||||
|
- frontend/src/pages/CreatorDashboard.tsx
|
||||||
|
- frontend/src/pages/ChapterReview.tsx
|
||||||
|
- frontend/src/pages/ChapterReview.module.css
|
||||||
|
key_decisions:
|
||||||
|
- Video picker uses consent API for creator video listing
|
||||||
|
- ChapterReview split into wrapper + detail component for clean param handling
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-04T06:11:59.484Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Wired ChapterReview into App routes (/creator/chapters, /creator/chapters/:videoId) with sidebar NavLink and video picker view
|
||||||
|
|
||||||
|
**Wired ChapterReview into App routes (/creator/chapters, /creator/chapters/:videoId) with sidebar NavLink and video picker view**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Added two protected lazy-loaded routes to App.tsx for the chapter review flow. Replaced the disabled Content placeholder in CreatorDashboard sidebar with an active Chapters NavLink. Added a VideoPicker component in ChapterReview.tsx that lists creator videos when no videoId is in the URL, using the consent API. Split ChapterReview into wrapper + ChapterReviewDetail for clean param handling.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
TypeScript compilation (npx tsc --noEmit) passes with no errors. All grep/test checks for ChapterReview in App.tsx, /creator/chapters in CreatorDashboard, file existence, and updateChapter in videos.ts pass.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 5000ms |
|
||||||
|
| 2 | `grep -q 'ChapterReview' src/App.tsx` | 0 | ✅ pass | 50ms |
|
||||||
|
| 3 | `grep -q '/creator/chapters' src/pages/CreatorDashboard.tsx` | 0 | ✅ pass | 50ms |
|
||||||
|
| 4 | `test -f src/pages/ChapterReview.tsx` | 0 | ✅ pass | 10ms |
|
||||||
|
| 5 | `test -f src/pages/ChapterReview.module.css` | 0 | ✅ pass | 10ms |
|
||||||
|
| 6 | `grep -q 'updateChapter' src/api/videos.ts` | 0 | ✅ pass | 50ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
ChapterReview split into wrapper + ChapterReviewDetail sub-component. Video picker uses consent API rather than a dedicated endpoint.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
Video picker uses consent API which only shows videos with consent records.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/App.tsx`
|
||||||
|
- `frontend/src/pages/CreatorDashboard.tsx`
|
||||||
|
- `frontend/src/pages/ChapterReview.tsx`
|
||||||
|
- `frontend/src/pages/ChapterReview.module.css`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
ChapterReview split into wrapper + ChapterReviewDetail sub-component. Video picker uses consent API rather than a dedicated endpoint.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
Video picker uses consent API which only shows videos with consent records.
|
||||||
|
|
@ -199,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" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
||||||
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
|
|
|
||||||
|
|
@ -285,3 +285,51 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Video Picker ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.videoPickerList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPickerCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-2, #1e293b);
|
||||||
|
border: 1px solid var(--border, #334155);
|
||||||
|
color: var(--text-primary, #e2e8f0);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPickerCard:hover {
|
||||||
|
background: var(--surface-3, #2d3a4d);
|
||||||
|
border-color: var(--accent, #22d3ee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPickerIcon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPickerInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPickerName {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import WaveSurfer from "wavesurfer.js";
|
import WaveSurfer from "wavesurfer.js";
|
||||||
import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
|
import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
|
||||||
import { SidebarNav } from "./CreatorDashboard";
|
import { SidebarNav } from "./CreatorDashboard";
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
type ChapterPatch,
|
type ChapterPatch,
|
||||||
type VideoDetail,
|
type VideoDetail,
|
||||||
} from "../api/videos";
|
} from "../api/videos";
|
||||||
|
import { fetchConsentList, type VideoConsentRead } from "../api/consent";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
import styles from "./ChapterReview.module.css";
|
import styles from "./ChapterReview.module.css";
|
||||||
|
|
||||||
|
|
@ -49,12 +50,84 @@ function regionColor(status: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Video Picker (shown when no videoId in URL) ───────────────────────────── */
|
||||||
|
|
||||||
|
function VideoPicker() {
|
||||||
|
const [videos, setVideos] = useState<VideoConsentRead[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchConsentList()
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setVideos(res.items);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled)
|
||||||
|
setError(err instanceof ApiError ? err.detail : "Failed to load videos");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<SidebarNav />
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.pageTitle}>Chapter Review</h1>
|
||||||
|
<p className={styles.subtitle}>Select a video to review its chapters</p>
|
||||||
|
|
||||||
|
{loading && <div className={styles.loadingState}>Loading videos…</div>}
|
||||||
|
{!loading && error && <div className={styles.errorState}>{error}</div>}
|
||||||
|
{!loading && !error && videos.length === 0 && (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<h2>No Videos</h2>
|
||||||
|
<p>You don't have any videos yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && videos.length > 0 && (
|
||||||
|
<div className={styles.videoPickerList}>
|
||||||
|
{videos.map((v) => (
|
||||||
|
<Link
|
||||||
|
key={v.source_video_id}
|
||||||
|
to={`/creator/chapters/${v.source_video_id}`}
|
||||||
|
className={styles.videoPickerCard}
|
||||||
|
>
|
||||||
|
<svg className={styles.videoPickerIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
|
</svg>
|
||||||
|
<div className={styles.videoPickerInfo}>
|
||||||
|
<span className={styles.videoPickerName}>{v.video_filename}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main component ────────────────────────────────────────────────────────── */
|
/* ── Main component ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default function ChapterReview() {
|
export default function ChapterReview() {
|
||||||
const { videoId } = useParams<{ videoId: string }>();
|
const { videoId } = useParams<{ videoId: string }>();
|
||||||
useDocumentTitle("Chapter Review");
|
useDocumentTitle("Chapter Review");
|
||||||
|
|
||||||
|
// If no videoId in URL, show the video picker
|
||||||
|
if (!videoId) return <VideoPicker />;
|
||||||
|
|
||||||
|
return <ChapterReviewDetail videoId={videoId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detail view (has a videoId) ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function ChapterReviewDetail({ videoId }: { videoId: string }) {
|
||||||
|
|
||||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
const [video, setVideo] = useState<VideoDetail | null>(null);
|
const [video, setVideo] = useState<VideoDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,13 @@ function SidebarNav() {
|
||||||
</svg>
|
</svg>
|
||||||
Dashboard
|
Dashboard
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<span
|
<NavLink to="/creator/chapters" className={linkClass}>
|
||||||
className={`${styles.sidebarLink} ${styles.sidebarLinkDisabled}`}
|
|
||||||
title="Coming soon"
|
|
||||||
aria-disabled="true"
|
|
||||||
>
|
|
||||||
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
</svg>
|
</svg>
|
||||||
Content
|
Chapters
|
||||||
</span>
|
</NavLink>
|
||||||
<NavLink to="/creator/consent" className={linkClass}>
|
<NavLink to="/creator/consent" className={linkClass}>
|
||||||
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue