perf: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and na…

- "frontend/src/hooks/useMediaSync.ts"
- "frontend/src/components/VideoPlayer.tsx"
- "frontend/src/components/PlayerControls.tsx"
- "frontend/src/App.css"
- "frontend/package.json"

GSD-Task: S01/T02
This commit is contained in:
jlightner 2026-04-03 23:46:03 +00:00
parent 87cb667848
commit 8069e9e2a3
10 changed files with 771 additions and 4 deletions

View file

@ -51,7 +51,7 @@ Both return 404 for non-existent video IDs.
- Estimate: 45m
- Files: backend/routers/videos.py, backend/schemas.py, backend/tests/test_video_detail.py
- Verify: cd backend && python -m pytest tests/test_video_detail.py -v
- [ ] **T02: Build VideoPlayer component with HLS, custom controls, and media sync hook** — Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript.
- [x] **T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts** — Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript.
## Steps

View file

@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M020/S01/T01",
"timestamp": 1775259763553,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 8,
"verdict": "pass"
},
{
"command": "python -m pytest tests/test_video_detail.py -v",
"exitCode": 4,
"durationMs": 242,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,83 @@
---
id: T02
parent: S01
milestone: M020
provides: []
requires: []
affects: []
key_files: ["frontend/src/hooks/useMediaSync.ts", "frontend/src/components/VideoPlayer.tsx", "frontend/src/components/PlayerControls.tsx", "frontend/src/App.css", "frontend/package.json"]
key_decisions: ["Cast videoRef to RefObject<HTMLVideoElement> at JSX site to satisfy React 18 strict ref typing"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "npx tsc --noEmit passes with zero errors. npm run build succeeds producing production bundle. hls.js is dynamically imported (lazy-loaded), not in main bundle."
completed_at: 2026-04-03T23:45:50.331Z
blocker_discovered: false
---
# T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts
> Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts
## What Happened
---
id: T02
parent: S01
milestone: M020
key_files:
- frontend/src/hooks/useMediaSync.ts
- frontend/src/components/VideoPlayer.tsx
- frontend/src/components/PlayerControls.tsx
- frontend/src/App.css
- frontend/package.json
key_decisions:
- Cast videoRef to RefObject<HTMLVideoElement> at JSX site to satisfy React 18 strict ref typing
duration: ""
verification_result: passed
completed_at: 2026-04-03T23:45:50.331Z
blocker_discovered: false
---
# T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts
**Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts**
## What Happened
Installed hls.js. Created useMediaSync hook managing shared playback state via video element event listeners. Created VideoPlayer with three source paths (HLS via lazy-loaded hls.js, Safari native HLS, direct mp4), null-src placeholder, and fatal error state. Created PlayerControls with play/pause, seek bar, time display, 6-option speed selector (0.52x), volume + mute, fullscreen, and keyboard shortcuts. Added ~180 lines of themed CSS with responsive stacking. Fixed React 18 ref type mismatch for video element.
## Verification
npx tsc --noEmit passes with zero errors. npm run build succeeds producing production bundle. hls.js is dynamically imported (lazy-loaded), not in main bundle.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 2800ms |
| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 3600ms |
## Deviations
Added RefObject cast at JSX ref site for React 18 strict ref typing compatibility.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/hooks/useMediaSync.ts`
- `frontend/src/components/VideoPlayer.tsx`
- `frontend/src/components/PlayerControls.tsx`
- `frontend/src/App.css`
- `frontend/package.json`
## Deviations
Added RefObject cast at JSX ref site for React 18 strict ref typing compatibility.
## Known Issues
None.

View file

@ -1,13 +1,14 @@
{
"name": "chrysopedia-web",
"version": "0.1.0",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chrysopedia-web",
"version": "0.1.0",
"version": "0.8.0",
"dependencies": {
"hls.js": "^1.6.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
@ -1463,6 +1464,12 @@
"node": ">=6.9.0"
}
},
"node_modules/hls.js": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View file

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"hls.js": "^1.6.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"

View file

@ -5860,3 +5860,202 @@ a.app-footer__about:hover,
margin-left: 0;
}
}
/* ── Video Player ──────────────────────────────────────────────────────────── */
.video-player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
overflow: hidden;
}
.video-player__video {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-player__unavailable {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
}
.video-player__unavailable-content {
text-align: center;
color: var(--color-text-muted);
}
.video-player__unavailable-content svg {
margin-bottom: 0.75rem;
opacity: 0.5;
}
.video-player__unavailable-content p {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-secondary);
margin: 0 0 0.25rem;
}
.video-player__unavailable-content span {
font-size: 0.875rem;
}
/* ── Player Controls ───────────────────────────────────────────────────────── */
.player-controls {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-header);
border-radius: 0 0 8px 8px;
}
.player-controls__btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--color-text-primary);
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
transition: background 0.15s;
}
.player-controls__btn:hover {
background: var(--color-bg-surface-hover);
}
.player-controls__time {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
white-space: nowrap;
min-width: 5.5rem;
}
/* Range input shared styles */
.player-controls__seek,
.player-controls__volume {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: linear-gradient(
to right,
var(--color-accent) 0%,
var(--color-accent) var(--progress, 0%),
var(--color-border) var(--progress, 0%),
var(--color-border) 100%
);
cursor: pointer;
outline: none;
}
.player-controls__seek {
flex: 1;
min-width: 0;
}
.player-controls__seek:disabled {
opacity: 0.4;
cursor: default;
}
.player-controls__volume {
width: 5rem;
flex-shrink: 0;
}
/* Thumb styling */
.player-controls__seek::-webkit-slider-thumb,
.player-controls__volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-accent);
border: none;
cursor: pointer;
}
.player-controls__seek::-moz-range-thumb,
.player-controls__volume::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-accent);
border: none;
cursor: pointer;
}
/* Speed button group */
.player-controls__speed {
display: flex;
gap: 2px;
margin: 0 0.25rem;
}
.player-controls__speed-btn {
font-size: 0.6875rem;
font-weight: 500;
padding: 0.15rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
line-height: 1;
}
.player-controls__speed-btn:hover {
border-color: var(--color-accent);
color: var(--color-text-primary);
}
.player-controls__speed-btn--active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #000;
font-weight: 600;
}
/* ── Responsive: player controls ───────────────────────────────────────────── */
@media (max-width: 640px) {
.player-controls {
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
}
.player-controls__seek {
order: -1;
width: 100%;
flex-basis: 100%;
}
.player-controls__speed {
gap: 1px;
}
.player-controls__speed-btn {
font-size: 0.625rem;
padding: 0.125rem 0.25rem;
}
.player-controls__volume {
width: 3.5rem;
}
}

View file

@ -0,0 +1,195 @@
import { useCallback, useEffect } from "react";
import type { MediaSyncState } from "../hooks/useMediaSync";
interface PlayerControlsProps {
mediaSync: MediaSyncState;
/** Ref to the container element for fullscreen requests */
containerRef?: React.RefObject<HTMLElement | null>;
}
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function PlayerControls({ mediaSync, containerRef }: PlayerControlsProps) {
const {
currentTime,
duration,
isPlaying,
playbackRate,
volume,
isMuted,
seekTo,
setPlaybackRate,
togglePlay,
setVolume,
toggleMute,
} = mediaSync;
const seekDisabled = !duration || duration === 0;
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
seekTo(parseFloat(e.target.value));
},
[seekTo],
);
const handleVolume = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value));
},
[setVolume],
);
const handleFullscreen = useCallback(() => {
const el = containerRef?.current;
if (!el) return;
if (document.fullscreenElement) {
void document.exitFullscreen();
} else {
void el.requestFullscreen();
}
}, [containerRef]);
// Keyboard shortcuts
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
// Don't intercept when typing in inputs
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
switch (e.key) {
case " ":
e.preventDefault();
togglePlay();
break;
case "ArrowLeft":
e.preventDefault();
seekTo(Math.max(0, currentTime - 5));
break;
case "ArrowRight":
e.preventDefault();
seekTo(Math.min(duration, currentTime + 5));
break;
case "ArrowUp":
e.preventDefault();
setVolume(Math.min(1, volume + 0.1));
break;
case "ArrowDown":
e.preventDefault();
setVolume(Math.max(0, volume - 0.1));
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [togglePlay, seekTo, setVolume, currentTime, duration, volume]);
// Seek bar progress as CSS variable for styling
const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0;
const volumeProgress = (isMuted ? 0 : volume) * 100;
return (
<div className="player-controls" role="toolbar" aria-label="Video playback controls">
{/* Play/Pause */}
<button
className="player-controls__btn"
onClick={togglePlay}
aria-label={isPlaying ? "Pause" : "Play"}
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 4h4v16H6zM14 4h4v16h-4z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
{/* Time display */}
<span className="player-controls__time">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
{/* Seek bar */}
<input
type="range"
className="player-controls__seek"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onChange={handleSeek}
disabled={seekDisabled}
aria-label="Seek"
style={{ "--progress": `${seekProgress}%` } as React.CSSProperties}
/>
{/* Speed selector */}
<div className="player-controls__speed" role="group" aria-label="Playback speed">
{SPEED_OPTIONS.map((rate) => (
<button
key={rate}
className={`player-controls__speed-btn${playbackRate === rate ? " player-controls__speed-btn--active" : ""}`}
onClick={() => setPlaybackRate(rate)}
aria-pressed={playbackRate === rate}
>
{rate}x
</button>
))}
</div>
{/* Volume */}
<button
className="player-controls__btn"
onClick={toggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12A4.5 4.5 0 0014 8.14v1.44l2.45 2.45c.03-.17.05-.34.05-.53zm2.5 0c0 .94-.2 1.82-.54 2.64l1.52 1.52A8.91 8.91 0 0021 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 003.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0014 8.14v7.72c1.48-.73 2.5-2.25 2.5-3.86zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
className="player-controls__volume"
min={0}
max={1}
step={0.05}
value={isMuted ? 0 : volume}
onChange={handleVolume}
aria-label="Volume"
style={{ "--progress": `${volumeProgress}%` } as React.CSSProperties}
/>
{/* Fullscreen */}
<button
className="player-controls__btn"
onClick={handleFullscreen}
aria-label="Fullscreen"
title="Fullscreen"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
);
}

View file

@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from "react";
import type { MediaSyncState } from "../hooks/useMediaSync";
interface VideoPlayerProps {
src: string | null;
startTime?: number;
mediaSync: MediaSyncState;
}
/**
* Video player with HLS support (lazy-loaded via dynamic import),
* Safari native HLS fallback, and direct .mp4 playback.
* Shows a placeholder when src is null.
*/
export default function VideoPlayer({ src, startTime, mediaSync }: VideoPlayerProps) {
const { videoRef } = mediaSync;
const hlsRef = useRef<import("hls.js").default | null>(null);
const [error, setError] = useState<string | null>(null);
const startTimeSeeked = useRef(false);
// Clamp startTime: NaN / negative → 0
const safeStart =
startTime != null && Number.isFinite(startTime) && startTime > 0
? startTime
: 0;
// Seek to startTime once after metadata loads
useEffect(() => {
const el = videoRef.current;
if (!el || safeStart === 0) return;
startTimeSeeked.current = false;
const onLoaded = () => {
if (!startTimeSeeked.current) {
el.currentTime = safeStart;
startTimeSeeked.current = true;
}
};
if (el.readyState >= 1) {
onLoaded();
} else {
el.addEventListener("loadedmetadata", onLoaded, { once: true });
return () => el.removeEventListener("loadedmetadata", onLoaded);
}
}, [videoRef, safeStart]);
// HLS / native source attachment
useEffect(() => {
const el = videoRef.current;
if (!el || !src) return;
setError(null);
let destroyed = false;
const isHlsUrl = src.endsWith(".m3u8");
// Check native HLS support (Safari)
const canPlayHlsNatively = el.canPlayType("application/vnd.apple.mpegurl") !== "";
if (isHlsUrl && !canPlayHlsNatively) {
// Lazy-load hls.js
void import("hls.js").then(({ default: Hls }) => {
if (destroyed) return;
if (!Hls.isSupported()) {
setError("HLS playback is not supported in this browser.");
return;
}
const hls = new Hls({
enableWorker: true,
startPosition: safeStart > 0 ? safeStart : -1,
});
hlsRef.current = hls;
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
console.error("[VideoPlayer] Fatal HLS error:", data.type, data.details);
setError(`Playback error: ${data.details}`);
hls.destroy();
hlsRef.current = null;
}
});
hls.loadSource(src);
hls.attachMedia(el);
});
} else {
// Native HLS (Safari) or plain .mp4
el.src = src;
}
return () => {
destroyed = true;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [src, videoRef, safeStart]);
if (!src) {
return (
<div className="video-player video-player__unavailable">
<div className="video-player__unavailable-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" />
<path d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12z" />
<line x1="4" y1="4" x2="20" y2="20" strokeLinecap="round" />
</svg>
<p>Video not available</p>
<span>The source video for this content has not been linked yet.</span>
</div>
</div>
);
}
if (error) {
return (
<div className="video-player video-player__unavailable">
<div className="video-player__unavailable-content">
<p>Playback Error</p>
<span>{error}</span>
</div>
</div>
);
}
return (
<div className="video-player">
<video
ref={videoRef as React.RefObject<HTMLVideoElement>}
className="video-player__video"
playsInline
preload="metadata"
/>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface MediaSyncState {
currentTime: number;
duration: number;
isPlaying: boolean;
playbackRate: number;
volume: number;
isMuted: boolean;
videoRef: React.RefObject<HTMLVideoElement | null>;
seekTo: (time: number) => void;
setPlaybackRate: (rate: number) => void;
togglePlay: () => void;
setVolume: (v: number) => void;
toggleMute: () => void;
}
/**
* Shared playback state hook. Owns the <video> ref and exposes
* current time, duration, play state, rate, volume, and control actions.
* Attach `videoRef` to a <video> element the hook listens for
* timeupdate, play, pause, ratechange, volumechange, and loadedmetadata.
*/
export function useMediaSync(): MediaSyncState {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackRate, setPlaybackRateState] = useState(1);
const [volume, setVolumeState] = useState(1);
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const el = videoRef.current;
if (!el) return;
const onTimeUpdate = () => setCurrentTime(el.currentTime);
const onDurationChange = () => setDuration(el.duration || 0);
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onRateChange = () => setPlaybackRateState(el.playbackRate);
const onVolumeChange = () => {
setVolumeState(el.volume);
setIsMuted(el.muted);
};
const onLoadedMetadata = () => {
setDuration(el.duration || 0);
setCurrentTime(el.currentTime);
};
el.addEventListener("timeupdate", onTimeUpdate);
el.addEventListener("durationchange", onDurationChange);
el.addEventListener("play", onPlay);
el.addEventListener("pause", onPause);
el.addEventListener("ratechange", onRateChange);
el.addEventListener("volumechange", onVolumeChange);
el.addEventListener("loadedmetadata", onLoadedMetadata);
return () => {
el.removeEventListener("timeupdate", onTimeUpdate);
el.removeEventListener("durationchange", onDurationChange);
el.removeEventListener("play", onPlay);
el.removeEventListener("pause", onPause);
el.removeEventListener("ratechange", onRateChange);
el.removeEventListener("volumechange", onVolumeChange);
el.removeEventListener("loadedmetadata", onLoadedMetadata);
};
}, []);
const seekTo = useCallback((time: number) => {
const el = videoRef.current;
if (!el) return;
const safe = Number.isFinite(time) && time >= 0 ? time : 0;
el.currentTime = Math.min(safe, el.duration || 0);
}, []);
const setPlaybackRate = useCallback((rate: number) => {
const el = videoRef.current;
if (!el) return;
const clamped = Math.max(0.5, Math.min(2, rate));
el.playbackRate = clamped;
}, []);
const togglePlay = useCallback(() => {
const el = videoRef.current;
if (!el) return;
if (el.paused) {
void el.play();
} else {
el.pause();
}
}, []);
const setVolume = useCallback((v: number) => {
const el = videoRef.current;
if (!el) return;
el.volume = Math.max(0, Math.min(1, v));
}, []);
const toggleMute = useCallback(() => {
const el = videoRef.current;
if (!el) return;
el.muted = !el.muted;
}, []);
return {
currentTime,
duration,
isPlaying,
playbackRate,
volume,
isMuted,
videoRef,
seekTo,
setPlaybackRate,
togglePlay,
setVolume,
toggleMute,
};
}

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/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/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.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/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"}