diff --git a/.gsd/milestones/M020/slices/S01/S01-PLAN.md b/.gsd/milestones/M020/slices/S01/S01-PLAN.md index 0933594..e138bca 100644 --- a/.gsd/milestones/M020/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M020/slices/S01/S01-PLAN.md @@ -107,7 +107,7 @@ Both return 404 for non-existent video IDs. - Estimate: 2h - Files: frontend/src/hooks/useMediaSync.ts, frontend/src/components/VideoPlayer.tsx, frontend/src/components/PlayerControls.tsx, frontend/src/App.css, frontend/package.json - Verify: cd frontend && npx tsc --noEmit && npm run build -- [ ] **T03: Build WatchPage, TranscriptSidebar, route wiring, and TechniquePage timestamp links** — Compose the full watch experience: TranscriptSidebar synced to playback, WatchPage layout, route in App.tsx, and clickable timestamp links on TechniquePage. +- [x] **T03: Built WatchPage with video player, synced transcript sidebar, lazy-loaded /watch/:videoId route, and clickable timestamp links on TechniquePage key moments** — Compose the full watch experience: TranscriptSidebar synced to playback, WatchPage layout, route in App.tsx, and clickable timestamp links on TechniquePage. ## Steps diff --git a/.gsd/milestones/M020/slices/S01/tasks/T02-VERIFY.json b/.gsd/milestones/M020/slices/S01/tasks/T02-VERIFY.json new file mode 100644 index 0000000..41efb07 --- /dev/null +++ b/.gsd/milestones/M020/slices/S01/tasks/T02-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M020/S01/T02", + "timestamp": 1775259963615, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 8, + "verdict": "pass" + }, + { + "command": "npx tsc --noEmit", + "exitCode": 1, + "durationMs": 821, + "verdict": "fail" + }, + { + "command": "npm run build", + "exitCode": 254, + "durationMs": 102, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M020/slices/S01/tasks/T03-SUMMARY.md b/.gsd/milestones/M020/slices/S01/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..0e51064 --- /dev/null +++ b/.gsd/milestones/M020/slices/S01/tasks/T03-SUMMARY.md @@ -0,0 +1,86 @@ +--- +id: T03 +parent: S01 +milestone: M020 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/api/videos.ts", "frontend/src/components/TranscriptSidebar.tsx", "frontend/src/pages/WatchPage.tsx", "frontend/src/App.tsx", "frontend/src/pages/TechniquePage.tsx", "frontend/src/App.css"] +key_decisions: ["TranscriptSidebar uses button elements for segments — semantic click targets with keyboard accessibility", "Transcript fetch failure is non-blocking — player works without sidebar"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "npx tsc --noEmit: zero type errors. npm run build: clean build, WatchPage code-split into separate chunk (10.71 KB)." +completed_at: 2026-04-03T23:49:51.368Z +blocker_discovered: false +--- + +# T03: Built WatchPage with video player, synced transcript sidebar, lazy-loaded /watch/:videoId route, and clickable timestamp links on TechniquePage key moments + +> Built WatchPage with video player, synced transcript sidebar, lazy-loaded /watch/:videoId route, and clickable timestamp links on TechniquePage key moments + +## What Happened +--- +id: T03 +parent: S01 +milestone: M020 +key_files: + - frontend/src/api/videos.ts + - frontend/src/components/TranscriptSidebar.tsx + - frontend/src/pages/WatchPage.tsx + - frontend/src/App.tsx + - frontend/src/pages/TechniquePage.tsx + - frontend/src/App.css +key_decisions: + - TranscriptSidebar uses button elements for segments — semantic click targets with keyboard accessibility + - Transcript fetch failure is non-blocking — player works without sidebar +duration: "" +verification_result: passed +completed_at: 2026-04-03T23:49:51.368Z +blocker_discovered: false +--- + +# T03: Built WatchPage with video player, synced transcript sidebar, lazy-loaded /watch/:videoId route, and clickable timestamp links on TechniquePage key moments + +**Built WatchPage with video player, synced transcript sidebar, lazy-loaded /watch/:videoId route, and clickable timestamp links on TechniquePage key moments** + +## What Happened + +Created API client (videos.ts) with TypeScript interfaces for VideoDetail and TranscriptSegment. Built TranscriptSidebar with O(log n) binary search for active segment, auto-scroll, and click-to-seek. Composed WatchPage with useMediaSync, VideoPlayer, PlayerControls, and TranscriptSidebar — transcript fetch failure is non-blocking. Added lazy-loaded route in App.tsx and updated TechniquePage to wrap key moment timestamps in Links to /watch/:videoId?t=X. Added responsive CSS grid layout (sidebar beside player on desktop, below on mobile). + +## Verification + +npx tsc --noEmit: zero type errors. npm run build: clean build, WatchPage code-split into separate chunk (10.71 KB). + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 2800ms | +| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 4700ms | + + +## Deviations + +Fixed TS2532 strict array indexing in binary search — tsc -b mode is stricter than --noEmit. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/api/videos.ts` +- `frontend/src/components/TranscriptSidebar.tsx` +- `frontend/src/pages/WatchPage.tsx` +- `frontend/src/App.tsx` +- `frontend/src/pages/TechniquePage.tsx` +- `frontend/src/App.css` + + +## Deviations +Fixed TS2532 strict array indexing in binary search — tsc -b mode is stricter than --noEmit. + +## Known Issues +None. diff --git a/frontend/src/App.css b/frontend/src/App.css index 954f45d..2dc1025 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -6059,3 +6059,148 @@ a.app-footer__about:hover, width: 3.5rem; } } + +/* ── Watch Page ──────────────────────────────────────────────────────────── */ + +.watch-page { + max-width: 90rem; + margin: 0 auto; + padding: 1.5rem var(--page-gutter, 1.5rem); +} + +.watch-page__header { + margin-bottom: 1.25rem; +} + +.watch-page__title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 0.25rem; +} + +.watch-page__creator { + font-size: 0.9rem; + color: var(--accent); + text-decoration: none; +} +.watch-page__creator:hover { + text-decoration: underline; +} + +.watch-page__content { + display: grid; + grid-template-columns: 1fr 22rem; + gap: 1.5rem; + align-items: start; +} + +.watch-page__player-area { + min-width: 0; /* prevent grid blowout */ +} + +.watch-page__transcript-error { + margin-top: 1rem; + font-size: 0.85rem; + color: var(--text-secondary); + font-style: italic; +} + +/* ── Transcript Sidebar ──────────────────────────────────────────────────── */ + +.transcript-sidebar { + border: 1px solid var(--border); + border-radius: var(--radius-md, 0.5rem); + background: var(--surface-secondary, var(--surface)); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: calc(100vh - 10rem); +} + +.transcript-sidebar__title { + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + padding: 0.75rem 1rem 0.5rem; + margin: 0; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.transcript-sidebar__empty { + padding: 2rem 1rem; + text-align: center; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.transcript-sidebar__list { + overflow-y: auto; + flex: 1 1 auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.transcript-segment { + display: flex; + gap: 0.75rem; + padding: 0.5rem 1rem; + border: none; + border-left: 3px solid transparent; + background: none; + width: 100%; + text-align: left; + cursor: pointer; + font: inherit; + color: var(--text-primary); + transition: background-color 150ms ease, border-color 150ms ease; +} + +.transcript-segment:hover { + background: var(--surface-hover, rgba(255, 255, 255, 0.04)); +} + +.transcript-segment--active { + border-left-color: var(--accent); + background: var(--surface-hover, rgba(255, 255, 255, 0.04)); +} + +.transcript-segment__time { + font-family: var(--font-mono, "JetBrains Mono", monospace); + font-size: 0.78rem; + color: var(--text-secondary); + white-space: nowrap; + flex-shrink: 0; + padding-top: 0.1em; +} + +.transcript-segment__text { + font-size: 0.88rem; + line-height: 1.45; + color: var(--text-primary); +} + +/* ── Technique timestamp links ───────────────────────────────────────────── */ + +.technique-source__time--link { + color: var(--accent); + text-decoration: none; +} +.technique-source__time--link:hover { + text-decoration: underline; +} + +/* ── Watch Page responsive ───────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .watch-page__content { + grid-template-columns: 1fr; + } + + .transcript-sidebar { + max-height: 20rem; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d926e7..a51162e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ const AdminTechniquePages = React.lazy(() => import("./pages/AdminTechniquePages const About = React.lazy(() => import("./pages/About")); const CreatorDashboard = React.lazy(() => import("./pages/CreatorDashboard")); const CreatorSettings = React.lazy(() => import("./pages/CreatorSettings")); +const WatchPage = React.lazy(() => import("./pages/WatchPage")); import AdminDropdown from "./components/AdminDropdown"; import AppFooter from "./components/AppFooter"; import SearchAutocomplete from "./components/SearchAutocomplete"; @@ -165,6 +166,7 @@ function AppShell() { } /> } /> } /> + }>} /> {/* Browse routes */} } /> diff --git a/frontend/src/api/videos.ts b/frontend/src/api/videos.ts new file mode 100644 index 0000000..025786c --- /dev/null +++ b/frontend/src/api/videos.ts @@ -0,0 +1,46 @@ +import { request, BASE } from "./client"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface VideoDetail { + id: string; + filename: string; + file_path: string; + duration_seconds: number | null; + content_type: string; + creator_id: string; + creator_name: string; + creator_slug: string; + video_url: string | null; + processing_status: string; + created_at: string; + updated_at: string; +} + +export interface TranscriptSegment { + id: string; + source_video_id: string; + start_time: number; + end_time: number; + text: string; + segment_index: number; + topic_label: string | null; +} + +export interface TranscriptResponse { + video_id: string; + segments: TranscriptSegment[]; + total: number; +} + +// ── API functions ──────────────────────────────────────────────────────────── + +export function fetchVideo(id: string): Promise { + return request(`${BASE}/videos/${encodeURIComponent(id)}`); +} + +export function fetchTranscript(videoId: string): Promise { + return request( + `${BASE}/videos/${encodeURIComponent(videoId)}/transcript`, + ); +} diff --git a/frontend/src/components/TranscriptSidebar.tsx b/frontend/src/components/TranscriptSidebar.tsx new file mode 100644 index 0000000..3267db7 --- /dev/null +++ b/frontend/src/components/TranscriptSidebar.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useCallback } from "react"; +import type { TranscriptSegment } from "../api/videos"; + +interface TranscriptSidebarProps { + segments: TranscriptSegment[]; + currentTime: number; + onSeek: (time: number) => void; +} + +/** + * Format seconds as MM:SS. + */ +function formatTimestamp(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +/** + * Binary search to find the active segment index. + * Returns the index of the segment where start_time <= currentTime < end_time, + * or -1 if no segment is active. + * Assumes segments are sorted by start_time. + */ +function findActiveSegment( + segments: TranscriptSegment[], + currentTime: number, +): number { + if (segments.length === 0) return -1; + + let lo = 0; + let hi = segments.length - 1; + let result = -1; + + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const seg = segments[mid]; + if (seg && seg.start_time <= currentTime) { + result = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + // result is the last segment whose start_time <= currentTime + // verify currentTime < end_time for that segment + const active = result >= 0 ? segments[result] : undefined; + if (active && currentTime < active.end_time) { + return result; + } + + return -1; +} + +/** + * Scrollable transcript sidebar synced to video playback. + * Active segment highlights with cyan border and auto-scrolls into view. + * Clicking a segment seeks the video to that timestamp. + */ +export default function TranscriptSidebar({ + segments, + currentTime, + onSeek, +}: TranscriptSidebarProps) { + const containerRef = useRef(null); + const activeIndexRef = useRef(-1); + + const activeIndex = findActiveSegment(segments, currentTime); + + // Auto-scroll active segment into view + useEffect(() => { + if (activeIndex < 0 || activeIndex === activeIndexRef.current) return; + activeIndexRef.current = activeIndex; + + const container = containerRef.current; + if (!container) return; + + const el = container.querySelector( + `[data-segment-index="${activeIndex}"]`, + ); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + }, [activeIndex]); + + const handleClick = useCallback( + (startTime: number) => { + onSeek(startTime); + }, + [onSeek], + ); + + if (segments.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx index 499cbea..afd3117 100644 --- a/frontend/src/pages/TechniquePage.tsx +++ b/frontend/src/pages/TechniquePage.tsx @@ -512,9 +512,18 @@ export default function TechniquePage() { {km.video_filename && ( {km.video_filename} )} - - {formatTime(km.start_time)}–{formatTime(km.end_time)} - + {km.source_video_id ? ( + + {formatTime(km.start_time)}–{formatTime(km.end_time)} + + ) : ( + + {formatTime(km.start_time)}–{formatTime(km.end_time)} + + )} {km.content_type} {km.summary} diff --git a/frontend/src/pages/WatchPage.tsx b/frontend/src/pages/WatchPage.tsx new file mode 100644 index 0000000..4007da3 --- /dev/null +++ b/frontend/src/pages/WatchPage.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { useParams, useSearchParams, Link } from "react-router-dom"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import { useMediaSync } from "../hooks/useMediaSync"; +import { fetchVideo, fetchTranscript } from "../api/videos"; +import type { VideoDetail, TranscriptSegment } from "../api/videos"; +import { ApiError } from "../api/client"; +import VideoPlayer from "../components/VideoPlayer"; +import PlayerControls from "../components/PlayerControls"; +import TranscriptSidebar from "../components/TranscriptSidebar"; + +export default function WatchPage() { + const { videoId } = useParams<{ videoId: string }>(); + const [searchParams] = useSearchParams(); + + // Parse ?t= param — clamp to 0 for NaN/negative + const rawT = parseFloat(searchParams.get("t") ?? ""); + const startTime = Number.isFinite(rawT) && rawT > 0 ? rawT : 0; + + const [video, setVideo] = useState(null); + const [segments, setSegments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [transcriptError, setTranscriptError] = useState(false); + + const mediaSync = useMediaSync(); + + useDocumentTitle(video ? `${video.filename} — Chrysopedia` : "Loading…"); + + // Fetch video detail + useEffect(() => { + if (!videoId) return; + let cancelled = false; + + setLoading(true); + setError(null); + setTranscriptError(false); + + (async () => { + try { + const v = await fetchVideo(videoId); + if (!cancelled) setVideo(v); + } catch (err) { + if (!cancelled) { + if (err instanceof ApiError && err.status === 404) { + setError("Video not found"); + } else { + setError("Failed to load video"); + } + } + return; + } + + // Fetch transcript separately — player works without it + try { + const t = await fetchTranscript(videoId); + if (!cancelled) setSegments(t.segments); + } catch { + if (!cancelled) setTranscriptError(true); + } + + if (!cancelled) setLoading(false); + })(); + + return () => { + cancelled = true; + }; + }, [videoId]); + + if (loading && !video) { + return ( +
+

+ Loading video… +

+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ ← Back to home +
+
+ ); + } + + if (!video) return null; + + return ( +
+
+

{video.filename}

+ {video.creator_name && video.creator_slug && ( + + {video.creator_name} + + )} +
+ +
+
+ + +
+ + +
+ + {transcriptError && ( +

+ Transcript unavailable — playback continues without it. +

+ )} +
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index d82d19f..e2a350a 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file