feat: Added shorts router with generate/list/download endpoints, fronte…

- "backend/routers/shorts.py"
- "backend/main.py"
- "frontend/src/api/shorts.ts"
- "frontend/src/pages/HighlightQueue.tsx"
- "frontend/src/pages/HighlightQueue.module.css"

GSD-Task: S03/T03
This commit is contained in:
jlightner 2026-04-04 09:52:01 +00:00
parent e0a6458bdc
commit c3d1afa2ce
6 changed files with 692 additions and 120 deletions

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings from config import get_settings
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, stats, techniques, topics, videos from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, stats, techniques, topics, videos
def _setup_logging() -> None: def _setup_logging() -> None:
@ -101,6 +101,7 @@ app.include_router(posts.router, prefix="/api/v1")
app.include_router(files.router, prefix="/api/v1") app.include_router(files.router, prefix="/api/v1")
app.include_router(reports.router, prefix="/api/v1") app.include_router(reports.router, prefix="/api/v1")
app.include_router(search.router, prefix="/api/v1") app.include_router(search.router, prefix="/api/v1")
app.include_router(shorts.router, prefix="/api/v1")
app.include_router(stats.router, prefix="/api/v1") app.include_router(stats.router, prefix="/api/v1")
app.include_router(techniques.router, prefix="/api/v1") app.include_router(techniques.router, prefix="/api/v1")
app.include_router(topics.router, prefix="/api/v1") app.include_router(topics.router, prefix="/api/v1")

204
backend/routers/shorts.py Normal file
View file

@ -0,0 +1,204 @@
"""Shorts generation API endpoints.
Trigger short generation from approved highlights, list generated shorts,
and get presigned download URLs.
"""
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_session
from models import (
FormatPreset,
GeneratedShort,
HighlightCandidate,
HighlightStatus,
ShortStatus,
)
logger = logging.getLogger("chrysopedia.shorts")
router = APIRouter(prefix="/admin/shorts", tags=["shorts"])
# ── Response schemas ─────────────────────────────────────────────────────────
class GeneratedShortResponse(BaseModel):
id: str
highlight_candidate_id: str
format_preset: str
status: str
error_message: str | None = None
file_size_bytes: int | None = None
duration_secs: float | None = None
width: int
height: int
created_at: datetime
model_config = {"from_attributes": True}
class ShortsListResponse(BaseModel):
shorts: list[GeneratedShortResponse]
class GenerateResponse(BaseModel):
status: str
message: str
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.post("/generate/{highlight_id}", response_model=GenerateResponse, status_code=202)
async def generate_shorts(
highlight_id: str,
db: AsyncSession = Depends(get_session),
):
"""Dispatch shorts generation for an approved highlight candidate.
Creates pending GeneratedShort rows for each format preset and dispatches
the Celery task. Returns 202 Accepted with status.
"""
# Validate highlight exists and is approved
stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id)
result = await db.execute(stmt)
highlight = result.scalar_one_or_none()
if highlight is None:
raise HTTPException(status_code=404, detail=f"Highlight not found: {highlight_id}")
if highlight.status != HighlightStatus.approved:
raise HTTPException(
status_code=400,
detail=f"Highlight must be approved before generating shorts (current: {highlight.status.value})",
)
# Check if shorts are already processing
existing_stmt = (
select(GeneratedShort)
.where(
GeneratedShort.highlight_candidate_id == highlight_id,
GeneratedShort.status.in_([ShortStatus.pending, ShortStatus.processing]),
)
)
existing_result = await db.execute(existing_stmt)
in_progress = existing_result.scalars().all()
if in_progress:
raise HTTPException(
status_code=409,
detail="Shorts generation already in progress for this highlight",
)
# Dispatch Celery task
from pipeline.stages import stage_generate_shorts
try:
task = stage_generate_shorts.delay(highlight_id)
logger.info(
"Shorts generation dispatched highlight_id=%s task_id=%s",
highlight_id,
task.id,
)
except Exception as exc:
logger.warning(
"Failed to dispatch shorts generation for highlight_id=%s: %s",
highlight_id,
exc,
)
raise HTTPException(
status_code=503,
detail="Shorts generation dispatch failed — Celery/Redis may be unavailable",
) from exc
return GenerateResponse(
status="dispatched",
message=f"Shorts generation dispatched for highlight {highlight_id}",
)
@router.get("/{highlight_id}", response_model=ShortsListResponse)
async def list_shorts(
highlight_id: str,
db: AsyncSession = Depends(get_session),
):
"""List all generated shorts for a highlight candidate."""
# Verify highlight exists
hl_stmt = select(HighlightCandidate.id).where(HighlightCandidate.id == highlight_id)
hl_result = await db.execute(hl_stmt)
if hl_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail=f"Highlight not found: {highlight_id}")
stmt = (
select(GeneratedShort)
.where(GeneratedShort.highlight_candidate_id == highlight_id)
.order_by(GeneratedShort.format_preset)
)
result = await db.execute(stmt)
shorts = result.scalars().all()
return ShortsListResponse(
shorts=[
GeneratedShortResponse(
id=str(s.id),
highlight_candidate_id=str(s.highlight_candidate_id),
format_preset=s.format_preset.value,
status=s.status.value,
error_message=s.error_message,
file_size_bytes=s.file_size_bytes,
duration_secs=s.duration_secs,
width=s.width,
height=s.height,
created_at=s.created_at,
)
for s in shorts
]
)
@router.get("/download/{short_id}")
async def download_short(
short_id: str,
db: AsyncSession = Depends(get_session),
):
"""Get a presigned download URL for a completed short."""
stmt = select(GeneratedShort).where(GeneratedShort.id == short_id)
result = await db.execute(stmt)
short = result.scalar_one_or_none()
if short is None:
raise HTTPException(status_code=404, detail=f"Short not found: {short_id}")
if short.status != ShortStatus.complete:
raise HTTPException(
status_code=400,
detail=f"Short is not complete (current: {short.status.value})",
)
if not short.minio_object_key:
raise HTTPException(
status_code=500,
detail="Short marked complete but has no storage key",
)
from minio_client import generate_download_url
try:
url = generate_download_url(short.minio_object_key)
except Exception as exc:
logger.error("Failed to generate download URL for short_id=%s: %s", short_id, exc)
raise HTTPException(
status_code=503,
detail="Failed to generate download URL — MinIO may be unavailable",
) from exc
return {"download_url": url, "format_preset": short.format_preset.value}

View file

@ -0,0 +1,53 @@
import { request, BASE } from "./client";
// ── Types ────────────────────────────────────────────────────────────────────
export interface GeneratedShort {
id: string;
highlight_candidate_id: string;
format_preset: "vertical" | "square" | "horizontal";
status: "pending" | "processing" | "complete" | "failed";
error_message: string | null;
file_size_bytes: number | null;
duration_secs: number | null;
width: number;
height: number;
created_at: string;
}
export interface ShortsListResponse {
shorts: GeneratedShort[];
}
export interface GenerateResponse {
status: string;
message: string;
}
export interface DownloadResponse {
download_url: string;
format_preset: string;
}
// ── API functions ────────────────────────────────────────────────────────────
export function generateShorts(highlightId: string): Promise<GenerateResponse> {
return request<GenerateResponse>(
`${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}`,
{ method: "POST" },
);
}
export function fetchShorts(highlightId: string): Promise<ShortsListResponse> {
return request<ShortsListResponse>(
`${BASE}/admin/shorts/${encodeURIComponent(highlightId)}`,
);
}
export function getShortDownloadUrl(
shortId: string,
): Promise<DownloadResponse> {
return request<DownloadResponse>(
`${BASE}/admin/shorts/download/${encodeURIComponent(shortId)}`,
);
}

View file

@ -357,6 +357,106 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
/* ── Shorts section ────────────────────────────────────────────────────────── */
.shortsSection {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0;
border-top: 1px solid var(--color-border);
}
.shortsLabel {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
white-space: nowrap;
}
.shortsBadges {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
align-items: center;
}
.shortItem {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.shortBadge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
}
.shortStatusPending {
background: var(--color-badge-pending-bg);
color: var(--color-badge-pending-text);
}
.shortStatusProcessing {
background: var(--color-accent-subtle, rgba(0, 255, 209, 0.12));
color: var(--color-accent, #00ffd1);
animation: pulse 1.5s ease-in-out infinite;
}
.shortStatusComplete {
background: var(--color-badge-approved-bg, rgba(34, 197, 94, 0.12));
color: var(--color-badge-approved-text, #22c55e);
}
.shortStatusFailed {
background: var(--color-badge-rejected-bg, rgba(239, 68, 68, 0.12));
color: var(--color-badge-rejected-text, #ef4444);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.downloadLink {
background: none;
border: none;
cursor: pointer;
color: var(--color-accent, #00ffd1);
font-size: 0.875rem;
font-weight: 700;
padding: 0 0.25rem;
line-height: 1;
transition: opacity 0.15s;
}
.downloadLink:hover {
opacity: 0.7;
}
.shortError {
color: var(--color-badge-rejected-text, #ef4444);
font-size: 0.75rem;
cursor: help;
}
.generateBtn {
background: var(--color-accent-subtle, rgba(0, 255, 209, 0.12));
color: var(--color-accent, #00ffd1);
border-color: transparent;
}
.generateBtn:hover {
background: rgba(0, 255, 209, 0.22);
}
/* ── Responsive ────────────────────────────────────────────────────────────── */ /* ── Responsive ────────────────────────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { SidebarNav } from "./CreatorDashboard"; import { SidebarNav } from "./CreatorDashboard";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { import {
@ -9,6 +9,12 @@ import {
type HighlightCandidate, type HighlightCandidate,
type ScoreBreakdown, type ScoreBreakdown,
} from "../api/highlights"; } from "../api/highlights";
import {
generateShorts,
fetchShorts,
getShortDownloadUrl,
type GeneratedShort,
} from "../api/shorts";
import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./HighlightQueue.module.css"; import styles from "./HighlightQueue.module.css";
@ -31,6 +37,44 @@ function statusBadgeClass(status: string): string {
} }
} }
function shortStatusClass(status: string): string {
switch (status) {
case "complete":
return styles.shortStatusComplete ?? "";
case "failed":
return styles.shortStatusFailed ?? "";
case "processing":
return styles.shortStatusProcessing ?? "";
default:
return styles.shortStatusPending ?? "";
}
}
function formatPresetLabel(preset: string): string {
switch (preset) {
case "vertical":
return "9:16";
case "square":
return "1:1";
case "horizontal":
return "16:9";
default:
return preset;
}
}
function canGenerateShorts(shorts: GeneratedShort[]): boolean {
if (shorts.length === 0) return true;
// Can re-generate if all existing shorts failed
return shorts.every((s) => s.status === "failed");
}
function hasProcessingShorts(shorts: GeneratedShort[]): boolean {
return shorts.some(
(s) => s.status === "pending" || s.status === "processing",
);
}
const BREAKDOWN_LABELS: { key: keyof ScoreBreakdown; label: string }[] = [ const BREAKDOWN_LABELS: { key: keyof ScoreBreakdown; label: string }[] = [
{ key: "duration_score", label: "Duration" }, { key: "duration_score", label: "Duration" },
{ key: "content_density_score", label: "Content Density" }, { key: "content_density_score", label: "Content Density" },
@ -43,6 +87,8 @@ const BREAKDOWN_LABELS: { key: keyof ScoreBreakdown; label: string }[] = [
type FilterTab = "all" | "shorts" | "approved" | "rejected"; type FilterTab = "all" | "shorts" | "approved" | "rejected";
const POLL_INTERVAL_MS = 5000;
/* ── Component ──────────────────────────────────────────────────────────────── */ /* ── Component ──────────────────────────────────────────────────────────────── */
export default function HighlightQueue() { export default function HighlightQueue() {
@ -57,6 +103,13 @@ export default function HighlightQueue() {
const [trimEnd, setTrimEnd] = useState<string>(""); const [trimEnd, setTrimEnd] = useState<string>("");
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
// Shorts state: highlight_id → GeneratedShort[]
const [shortsMap, setShortsMap] = useState<Map<string, GeneratedShort[]>>(
new Map(),
);
const [generatingIds, setGeneratingIds] = useState<Set<string>>(new Set());
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadHighlights = useCallback(async (tab: FilterTab) => { const loadHighlights = useCallback(async (tab: FilterTab) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -75,10 +128,83 @@ export default function HighlightQueue() {
} }
}, []); }, []);
// Load shorts for all approved highlights
const loadShortsForHighlights = useCallback(
async (hls: HighlightCandidate[]) => {
const approved = hls.filter((h) => h.status === "approved");
const newMap = new Map<string, GeneratedShort[]>();
await Promise.all(
approved.map(async (h) => {
try {
const res = await fetchShorts(h.id);
newMap.set(h.id, res.shorts);
} catch {
// Non-critical — leave empty
newMap.set(h.id, []);
}
}),
);
setShortsMap(newMap);
},
[],
);
useEffect(() => { useEffect(() => {
loadHighlights(activeTab); loadHighlights(activeTab);
}, [activeTab, loadHighlights]); }, [activeTab, loadHighlights]);
// After highlights load, fetch shorts for approved ones
useEffect(() => {
if (highlights.length > 0) {
loadShortsForHighlights(highlights);
}
}, [highlights, loadShortsForHighlights]);
// Polling: when any highlight has processing shorts, poll every 5s
useEffect(() => {
const anyProcessing = Array.from(shortsMap.values()).some(hasProcessingShorts);
if (anyProcessing) {
if (!pollRef.current) {
pollRef.current = setInterval(() => {
// Refresh shorts for highlights that have processing items
const processingIds = Array.from(shortsMap.entries())
.filter(([, shorts]) => hasProcessingShorts(shorts))
.map(([id]) => id);
Promise.all(
processingIds.map(async (id) => {
try {
const res = await fetchShorts(id);
setShortsMap((prev) => {
const next = new Map(prev);
next.set(id, res.shorts);
return next;
});
} catch {
// ignore poll errors
}
}),
);
}, POLL_INTERVAL_MS);
}
} else {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [shortsMap]);
const handleTabChange = (tab: FilterTab) => { const handleTabChange = (tab: FilterTab) => {
setActiveTab(tab); setActiveTab(tab);
setExpandedId(null); setExpandedId(null);
@ -158,6 +284,42 @@ export default function HighlightQueue() {
} }
}; };
const handleGenerateShorts = async (highlightId: string) => {
setGeneratingIds((prev) => new Set(prev).add(highlightId));
setError(null);
try {
await generateShorts(highlightId);
// Immediately fetch shorts to show pending status
const res = await fetchShorts(highlightId);
setShortsMap((prev) => {
const next = new Map(prev);
next.set(highlightId, res.shorts);
return next;
});
} catch (err) {
setError(
err instanceof ApiError ? err.detail : "Failed to dispatch shorts generation",
);
} finally {
setGeneratingIds((prev) => {
const next = new Set(prev);
next.delete(highlightId);
return next;
});
}
};
const handleDownload = async (shortId: string) => {
try {
const res = await getShortDownloadUrl(shortId);
window.open(res.download_url, "_blank");
} catch (err) {
setError(
err instanceof ApiError ? err.detail : "Failed to get download URL",
);
}
};
const tabs: { key: FilterTab; label: string }[] = [ const tabs: { key: FilterTab; label: string }[] = [
{ key: "all", label: "All" }, { key: "all", label: "All" },
{ key: "shorts", label: "Shorts" }, { key: "shorts", label: "Shorts" },
@ -206,7 +368,14 @@ export default function HighlightQueue() {
{/* Candidate list */} {/* Candidate list */}
{!loading && highlights.length > 0 && ( {!loading && highlights.length > 0 && (
<div className={styles.candidateList}> <div className={styles.candidateList}>
{highlights.map((h) => ( {highlights.map((h) => {
const shorts = shortsMap.get(h.id) ?? [];
const isGenerating = generatingIds.has(h.id);
const showGenerateBtn =
h.status === "approved" && canGenerateShorts(shorts);
const showShortsStatus = shorts.length > 0;
return (
<div key={h.id} className={styles.candidateCard}> <div key={h.id} className={styles.candidateCard}>
{/* Header */} {/* Header */}
<div className={styles.candidateHeader}> <div className={styles.candidateHeader}>
@ -260,6 +429,41 @@ export default function HighlightQueue() {
</div> </div>
)} )}
{/* Shorts status badges */}
{showShortsStatus && (
<div className={styles.shortsSection}>
<span className={styles.shortsLabel}>Shorts</span>
<div className={styles.shortsBadges}>
{shorts.map((s) => (
<div key={s.id} className={styles.shortItem}>
<span
className={`${styles.shortBadge} ${shortStatusClass(s.status)}`}
>
{formatPresetLabel(s.format_preset)}{" "}
{s.status}
</span>
{s.status === "complete" && (
<button
className={styles.downloadLink}
onClick={() => handleDownload(s.id)}
>
</button>
)}
{s.status === "failed" && s.error_message && (
<span
className={styles.shortError}
title={s.error_message}
>
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Action buttons */} {/* Action buttons */}
<div className={styles.actions}> <div className={styles.actions}>
<button <button
@ -283,6 +487,15 @@ export default function HighlightQueue() {
> >
{expandedId === h.id ? "Close" : "Trim"} {expandedId === h.id ? "Close" : "Trim"}
</button> </button>
{showGenerateBtn && (
<button
className={`${styles.actionBtn} ${styles.generateBtn}`}
disabled={isGenerating}
onClick={() => handleGenerateShorts(h.id)}
>
{isGenerating ? "Generating…" : "Generate Shorts"}
</button>
)}
</div> </div>
{/* Trim panel */} {/* Trim panel */}
@ -328,7 +541,8 @@ export default function HighlightQueue() {
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </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/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.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/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.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/ToggleSwitch.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/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.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"} {"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/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.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/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.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/ToggleSwitch.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/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.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"}