feat: Added collapsible inline video player to TechniquePage with chapt…
- "frontend/src/pages/TechniquePage.tsx" - "frontend/src/App.css" GSD-Task: S02/T02
This commit is contained in:
parent
fa7e4983c7
commit
86d554a56f
2 changed files with 212 additions and 7 deletions
|
|
@ -1815,6 +1815,104 @@ a.app-footer__repo:hover {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inline player (collapsible) ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.technique-player {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface-raised, var(--color-bg-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__toggle:hover {
|
||||||
|
background: var(--color-surface-hover, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__toggle-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
width: 1em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__collapse {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__collapse--open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__inner {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__video-select {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0.875rem 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__video {
|
||||||
|
max-height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__video .video-player {
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technique-player__video .video-player__video {
|
||||||
|
max-height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seek button in bibliography */
|
||||||
|
.technique-source__time--seek {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.technique-player__video {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
.technique-player__video .video-player,
|
||||||
|
.technique-player__video .video-player__video {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.technique-prose {
|
.technique-prose {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* Right column: ToC (sticky, scrolls with viewer).
|
* Right column: ToC (sticky, scrolls with viewer).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
fetchTechnique,
|
fetchTechnique,
|
||||||
|
|
@ -17,12 +17,17 @@ import {
|
||||||
type TechniquePageVersionDetail,
|
type TechniquePageVersionDetail,
|
||||||
type BodySectionV2,
|
type BodySectionV2,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { fetchChapters, type Chapter } from "../api/videos";
|
||||||
|
import { BASE } from "../api/client";
|
||||||
import ReportIssueModal from "../components/ReportIssueModal";
|
import ReportIssueModal from "../components/ReportIssueModal";
|
||||||
import CopyLinkButton from "../components/CopyLinkButton";
|
import CopyLinkButton from "../components/CopyLinkButton";
|
||||||
import CreatorAvatar from "../components/CreatorAvatar";
|
import CreatorAvatar from "../components/CreatorAvatar";
|
||||||
|
import VideoPlayer from "../components/VideoPlayer";
|
||||||
|
import PlayerControls from "../components/PlayerControls";
|
||||||
import TableOfContents, { slugify } from "../components/TableOfContents";
|
import TableOfContents, { slugify } from "../components/TableOfContents";
|
||||||
import { parseCitations } from "../utils/citations";
|
import { parseCitations } from "../utils/citations";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
|
import { useMediaSync } from "../hooks/useMediaSync";
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
|
|
@ -94,6 +99,14 @@ export default function TechniquePage() {
|
||||||
useState<TechniquePageVersionDetail | null>(null);
|
useState<TechniquePageVersionDetail | null>(null);
|
||||||
const [versionLoading, setVersionLoading] = useState(false);
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Inline player
|
||||||
|
const [playerOpen, setPlayerOpen] = useState(false);
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
|
const [activeVideoId, setActiveVideoId] = useState<string | null>(null);
|
||||||
|
const mediaSync = useMediaSync();
|
||||||
|
const playerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Load technique + version list
|
// Load technique + version list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
|
@ -169,6 +182,46 @@ export default function TechniquePage() {
|
||||||
};
|
};
|
||||||
}, [slug, selectedVersion]);
|
}, [slug, selectedVersion]);
|
||||||
|
|
||||||
|
// Derive initial video ID from technique data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!technique) return;
|
||||||
|
const firstVideoId =
|
||||||
|
technique.key_moments[0]?.source_video_id ||
|
||||||
|
technique.source_videos[0]?.id ||
|
||||||
|
null;
|
||||||
|
setActiveVideoId(firstVideoId);
|
||||||
|
}, [technique]);
|
||||||
|
|
||||||
|
// Fetch chapters when activeVideoId changes and player is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeVideoId || !playerOpen) {
|
||||||
|
setChapters([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchChapters(activeVideoId);
|
||||||
|
if (!cancelled) setChapters(res.chapters);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setChapters([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [activeVideoId, playerOpen]);
|
||||||
|
|
||||||
|
// Seek inline player and scroll into view
|
||||||
|
const seekInlinePlayer = useCallback((time: number) => {
|
||||||
|
mediaSync.seekTo(time);
|
||||||
|
playerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}, [mediaSync]);
|
||||||
|
|
||||||
|
// Build unique source videos list for the selector
|
||||||
|
const sourceVideos = useMemo(() => {
|
||||||
|
if (!technique) return [];
|
||||||
|
return technique.source_videos ?? [];
|
||||||
|
}, [technique]);
|
||||||
|
|
||||||
// --- Scroll-spy: activeId for ToC ---
|
// --- Scroll-spy: activeId for ToC ---
|
||||||
const [activeId, setActiveId] = useState<string>("");
|
const [activeId, setActiveId] = useState<string>("");
|
||||||
const titleBarRef = useRef<HTMLDivElement>(null);
|
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -447,6 +500,50 @@ export default function TechniquePage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline video player — collapsible */}
|
||||||
|
{activeVideoId && (
|
||||||
|
<section className="technique-player" ref={playerRef}>
|
||||||
|
<button
|
||||||
|
className="technique-player__toggle"
|
||||||
|
onClick={() => setPlayerOpen((prev) => !prev)}
|
||||||
|
aria-expanded={playerOpen}
|
||||||
|
>
|
||||||
|
<span className="technique-player__toggle-icon">{playerOpen ? "▼" : "▶"}</span>
|
||||||
|
{playerOpen
|
||||||
|
? `Playing: ${sourceVideos.find((v) => v.id === activeVideoId)?.filename ?? "Video"}`
|
||||||
|
: "Preview Key Moments"}
|
||||||
|
</button>
|
||||||
|
<div className={`technique-player__collapse ${playerOpen ? "technique-player__collapse--open" : ""}`}>
|
||||||
|
<div className="technique-player__inner">
|
||||||
|
{sourceVideos.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="technique-player__video-select"
|
||||||
|
value={activeVideoId}
|
||||||
|
onChange={(e) => setActiveVideoId(e.target.value)}
|
||||||
|
>
|
||||||
|
{sourceVideos.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.filename}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div className="technique-player__video" ref={playerContainerRef}>
|
||||||
|
<VideoPlayer
|
||||||
|
src={`${BASE}/videos/${activeVideoId}/stream`}
|
||||||
|
mediaSync={mediaSync}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PlayerControls
|
||||||
|
mediaSync={mediaSync}
|
||||||
|
containerRef={playerContainerRef}
|
||||||
|
chapters={chapters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Study guide prose — body_sections */}
|
{/* Study guide prose — body_sections */}
|
||||||
{displaySections &&
|
{displaySections &&
|
||||||
(Array.isArray(displaySections) ? displaySections.length > 0 : Object.keys(displaySections).length > 0) && (
|
(Array.isArray(displaySections) ? displaySections.length > 0 : Object.keys(displaySections).length > 0) && (
|
||||||
|
|
@ -513,12 +610,22 @@ export default function TechniquePage() {
|
||||||
<span className="technique-source__file">{km.video_filename}</span>
|
<span className="technique-source__file">{km.video_filename}</span>
|
||||||
)}
|
)}
|
||||||
{km.source_video_id ? (
|
{km.source_video_id ? (
|
||||||
|
playerOpen && km.source_video_id === activeVideoId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="technique-source__time technique-source__time--link technique-source__time--seek"
|
||||||
|
onClick={() => seekInlinePlayer(km.start_time)}
|
||||||
|
>
|
||||||
|
{formatTime(km.start_time)}–{formatTime(km.end_time)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to={`/watch/${km.source_video_id}?t=${km.start_time}`}
|
to={`/watch/${km.source_video_id}?t=${km.start_time}`}
|
||||||
className="technique-source__time technique-source__time--link"
|
className="technique-source__time technique-source__time--link"
|
||||||
>
|
>
|
||||||
{formatTime(km.start_time)}–{formatTime(km.end_time)}
|
{formatTime(km.start_time)}–{formatTime(km.end_time)}
|
||||||
</Link>
|
</Link>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="technique-source__time">
|
<span className="technique-source__time">
|
||||||
{formatTime(km.start_time)}–{formatTime(km.end_time)}
|
{formatTime(km.start_time)}–{formatTime(km.end_time)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue