feat: Built WatchPage with video player, synced transcript sidebar, laz…

- "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"

GSD-Task: S01/T03
This commit is contained in:
jlightner 2026-04-03 23:50:15 +00:00
parent 8069e9e2a3
commit 8417f0e9e0
10 changed files with 578 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {
<Route path="/" element={<Home />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/techniques/:slug" element={<TechniquePage />} />
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
{/* Browse routes */}
<Route path="/creators" element={<CreatorsBrowse />} />

View file

@ -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<VideoDetail> {
return request<VideoDetail>(`${BASE}/videos/${encodeURIComponent(id)}`);
}
export function fetchTranscript(videoId: string): Promise<TranscriptResponse> {
return request<TranscriptResponse>(
`${BASE}/videos/${encodeURIComponent(videoId)}/transcript`,
);
}

View file

@ -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<HTMLDivElement>(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<HTMLElement>(
`[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 (
<aside className="transcript-sidebar">
<h3 className="transcript-sidebar__title">Transcript</h3>
<p className="transcript-sidebar__empty">No transcript available</p>
</aside>
);
}
return (
<aside className="transcript-sidebar" ref={containerRef}>
<h3 className="transcript-sidebar__title">Transcript</h3>
<div className="transcript-sidebar__list">
{segments.map((seg, idx) => (
<button
key={seg.id}
type="button"
data-segment-index={idx}
className={`transcript-segment${idx === activeIndex ? " transcript-segment--active" : ""}`}
onClick={() => handleClick(seg.start_time)}
>
<span className="transcript-segment__time">
{formatTimestamp(seg.start_time)}
</span>
<span className="transcript-segment__text">{seg.text}</span>
</button>
))}
</div>
</aside>
);
}

View file

@ -512,9 +512,18 @@ export default function TechniquePage() {
{km.video_filename && (
<span className="technique-source__file">{km.video_filename}</span>
)}
<span className="technique-source__time">
{formatTime(km.start_time)}{formatTime(km.end_time)}
</span>
{km.source_video_id ? (
<Link
to={`/watch/${km.source_video_id}?t=${km.start_time}`}
className="technique-source__time technique-source__time--link"
>
{formatTime(km.start_time)}{formatTime(km.end_time)}
</Link>
) : (
<span className="technique-source__time">
{formatTime(km.start_time)}{formatTime(km.end_time)}
</span>
)}
<span className="technique-source__type">{km.content_type}</span>
</span>
<span className="technique-source__summary">{km.summary}</span>

View file

@ -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<VideoDetail | null>(null);
const [segments, setSegments] = useState<TranscriptSegment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="watch-page watch-page--loading">
<p style={{ color: "var(--text-secondary)", textAlign: "center", padding: "4rem 0" }}>
Loading video
</p>
</div>
);
}
if (error) {
return (
<div className="watch-page watch-page--error">
<div style={{ textAlign: "center", padding: "4rem 0" }}>
<h2 style={{ color: "var(--text-primary)", marginBottom: "0.5rem" }}>{error}</h2>
<Link to="/" style={{ color: "var(--accent)" }}> Back to home</Link>
</div>
</div>
);
}
if (!video) return null;
return (
<div className="watch-page">
<header className="watch-page__header">
<h1 className="watch-page__title">{video.filename}</h1>
{video.creator_name && video.creator_slug && (
<Link
to={`/creators/${video.creator_slug}`}
className="watch-page__creator"
>
{video.creator_name}
</Link>
)}
</header>
<div className="watch-page__content">
<div className="watch-page__player-area">
<VideoPlayer
src={video.video_url ?? null}
startTime={startTime}
mediaSync={mediaSync}
/>
<PlayerControls mediaSync={mediaSync} />
</div>
<TranscriptSidebar
segments={segments}
currentTime={mediaSync.currentTime}
onSeek={mediaSync.seekTo}
/>
</div>
{transcriptError && (
<p className="watch-page__transcript-error">
Transcript unavailable playback continues without it.
</p>
)}
</div>
);
}

View file

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