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:
parent
8069e9e2a3
commit
8417f0e9e0
10 changed files with 578 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
30
.gsd/milestones/M020/slices/S01/tasks/T02-VERIFY.json
Normal file
30
.gsd/milestones/M020/slices/S01/tasks/T02-VERIFY.json
Normal 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
|
||||
}
|
||||
86
.gsd/milestones/M020/slices/S01/tasks/T03-SUMMARY.md
Normal file
86
.gsd/milestones/M020/slices/S01/tasks/T03-SUMMARY.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
46
frontend/src/api/videos.ts
Normal file
46
frontend/src/api/videos.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
124
frontend/src/components/TranscriptSidebar.tsx
Normal file
124
frontend/src/components/TranscriptSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
131
frontend/src/pages/WatchPage.tsx
Normal file
131
frontend/src/pages/WatchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue