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 0007528e77
commit 84d8dc4455
9 changed files with 800 additions and 121 deletions

View file

@ -132,7 +132,7 @@ Create the pure ffmpeg wrapper module with 3 format presets, then wire a Celery
- Estimate: 45m
- Files: backend/pipeline/shorts_generator.py, backend/pipeline/stages.py, backend/minio_client.py
- Verify: cd backend && python -c "from pipeline.shorts_generator import extract_clip, PRESETS, resolve_video_path; print('OK')" && python -c "from pipeline.stages import stage_generate_shorts; print('OK')"
- [ ] **T03: Add shorts API endpoints and frontend generate button with status display** — ## Description
- [x] **T03: Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling** — ## Description
Expose the shorts pipeline through API endpoints (trigger generation, list shorts, download link) and add a "Generate Shorts" button to the HighlightQueue UI for approved highlights with status badges and download links.

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M023/S03/T02",
"timestamp": 1775296060809,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 9,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,91 @@
---
id: T03
parent: S03
milestone: M023
provides: []
requires: []
affects: []
key_files: ["backend/routers/shorts.py", "backend/main.py", "frontend/src/api/shorts.ts", "frontend/src/pages/HighlightQueue.tsx", "frontend/src/pages/HighlightQueue.module.css"]
key_decisions: ["Show generate button only on approved highlights with no in-progress shorts (or all-failed)", "Poll every 5s only for highlights with pending/processing shorts, stop when all settle", "Download opens presigned URL in new tab"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "All 8 verification checks passed: router registered in main.py, router imports clean, TypeScript compiles with zero errors, frontend production build succeeds, model imports OK, ffmpeg in Dockerfile, video_source_path in config, chrysopedia_videos volume mount present."
completed_at: 2026-04-04T09:51:41.379Z
blocker_discovered: false
---
# T03: Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling
> Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling
## What Happened
---
id: T03
parent: S03
milestone: M023
key_files:
- backend/routers/shorts.py
- backend/main.py
- frontend/src/api/shorts.ts
- frontend/src/pages/HighlightQueue.tsx
- frontend/src/pages/HighlightQueue.module.css
key_decisions:
- Show generate button only on approved highlights with no in-progress shorts (or all-failed)
- Poll every 5s only for highlights with pending/processing shorts, stop when all settle
- Download opens presigned URL in new tab
duration: ""
verification_result: passed
completed_at: 2026-04-04T09:51:41.381Z
blocker_discovered: false
---
# T03: Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling
**Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling**
## What Happened
Created backend/routers/shorts.py with three endpoints: POST generate (validates approved status, checks no in-progress shorts, dispatches Celery task, returns 202), GET list (returns all shorts for a highlight), GET download (returns presigned MinIO URL for completed shorts). Registered the router in main.py. Created frontend/src/api/shorts.ts with typed API client. Updated HighlightQueue.tsx with shorts state management, generate button on eligible highlights, per-preset status badges with color-coded states, download buttons, and 5s polling while shorts are processing. Added CSS styles including a pulsing animation for processing state.
## Verification
All 8 verification checks passed: router registered in main.py, router imports clean, TypeScript compiles with zero errors, frontend production build succeeds, model imports OK, ffmpeg in Dockerfile, video_source_path in config, chrysopedia_videos volume mount present.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `grep -q 'shorts' backend/main.py` | 0 | ✅ pass | 100ms |
| 2 | `cd backend && python -c "from routers.shorts import router; print('OK')"` | 0 | ✅ pass | 5600ms |
| 3 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 5600ms |
| 4 | `cd frontend && npm run build` | 0 | ✅ pass | 6300ms |
| 5 | `cd backend && python -c "from models import GeneratedShort, FormatPreset, ShortStatus; print('OK')"` | 0 | ✅ pass | 6300ms |
| 6 | `grep ffmpeg docker/Dockerfile.api` | 0 | ✅ pass | 100ms |
| 7 | `grep video_source_path backend/config.py` | 0 | ✅ pass | 100ms |
| 8 | `grep chrysopedia_videos docker-compose.yml` | 0 | ✅ pass | 100ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `backend/routers/shorts.py`
- `backend/main.py`
- `frontend/src/api/shorts.ts`
- `frontend/src/pages/HighlightQueue.tsx`
- `frontend/src/pages/HighlightQueue.module.css`
## Deviations
None.
## Known Issues
None.

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
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:
@ -101,6 +101,7 @@ app.include_router(posts.router, prefix="/api/v1")
app.include_router(files.router, prefix="/api/v1")
app.include_router(reports.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(techniques.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;
}
/* ── 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 ────────────────────────────────────────────────────────────── */
@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 { ApiError } from "../api/client";
import {
@ -9,6 +9,12 @@ import {
type HighlightCandidate,
type ScoreBreakdown,
} from "../api/highlights";
import {
generateShorts,
fetchShorts,
getShortDownloadUrl,
type GeneratedShort,
} from "../api/shorts";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
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 }[] = [
{ key: "duration_score", label: "Duration" },
{ 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";
const POLL_INTERVAL_MS = 5000;
/* ── Component ──────────────────────────────────────────────────────────────── */
export default function HighlightQueue() {
@ -57,6 +103,13 @@ export default function HighlightQueue() {
const [trimEnd, setTrimEnd] = useState<string>("");
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) => {
setLoading(true);
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(() => {
loadHighlights(activeTab);
}, [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) => {
setActiveTab(tab);
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 }[] = [
{ key: "all", label: "All" },
{ key: "shorts", label: "Shorts" },
@ -206,7 +368,14 @@ export default function HighlightQueue() {
{/* Candidate list */}
{!loading && highlights.length > 0 && (
<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}>
{/* Header */}
<div className={styles.candidateHeader}>
@ -260,6 +429,41 @@ export default function HighlightQueue() {
</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 */}
<div className={styles.actions}>
<button
@ -283,6 +487,15 @@ export default function HighlightQueue() {
>
{expandedId === h.id ? "Close" : "Trim"}
</button>
{showGenerateBtn && (
<button
className={`${styles.actionBtn} ${styles.generateBtn}`}
disabled={isGenerating}
onClick={() => handleGenerateShorts(h.id)}
>
{isGenerating ? "Generating…" : "Generate Shorts"}
</button>
)}
</div>
{/* Trim panel */}
@ -328,7 +541,8 @@ export default function HighlightQueue() {
</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"}