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:
parent
0007528e77
commit
84d8dc4455
9 changed files with 800 additions and 121 deletions
|
|
@ -132,7 +132,7 @@ Create the pure ffmpeg wrapper module with 3 format presets, then wire a Celery
|
||||||
- Estimate: 45m
|
- Estimate: 45m
|
||||||
- Files: backend/pipeline/shorts_generator.py, backend/pipeline/stages.py, backend/minio_client.py
|
- 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')"
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
16
.gsd/milestones/M023/slices/S03/tasks/T02-VERIFY.json
Normal file
16
.gsd/milestones/M023/slices/S03/tasks/T02-VERIFY.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
91
.gsd/milestones/M023/slices/S03/tasks/T03-SUMMARY.md
Normal file
91
.gsd/milestones/M023/slices/S03/tasks/T03-SUMMARY.md
Normal 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.
|
||||||
|
|
@ -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
204
backend/routers/shorts.py
Normal 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}
|
||||||
53
frontend/src/api/shorts.ts
Normal file
53
frontend/src/api/shorts.ts
Normal 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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,129 +368,181 @@ 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) => {
|
||||||
<div key={h.id} className={styles.candidateCard}>
|
const shorts = shortsMap.get(h.id) ?? [];
|
||||||
{/* Header */}
|
const isGenerating = generatingIds.has(h.id);
|
||||||
<div className={styles.candidateHeader}>
|
const showGenerateBtn =
|
||||||
<span className={styles.candidateTitle}>
|
h.status === "approved" && canGenerateShorts(shorts);
|
||||||
{h.key_moment?.title ?? "Untitled highlight"}
|
const showShortsStatus = shorts.length > 0;
|
||||||
</span>
|
|
||||||
<span className={styles.candidateDuration}>
|
|
||||||
{formatDuration(h.duration_secs)}
|
|
||||||
</span>
|
|
||||||
{h.duration_secs <= 60 && (
|
|
||||||
<span className={styles.shortsBadge}>Short</span>
|
|
||||||
)}
|
|
||||||
<span className={`${styles.badge} ${statusBadgeClass(h.status)}`}>
|
|
||||||
{h.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Composite score */}
|
return (
|
||||||
<div className={styles.compositeScore}>
|
<div key={h.id} className={styles.candidateCard}>
|
||||||
<span className={styles.compositeLabel}>
|
{/* Header */}
|
||||||
{Math.round(h.score * 100)}%
|
<div className={styles.candidateHeader}>
|
||||||
</span>
|
<span className={styles.candidateTitle}>
|
||||||
<div className={styles.scoreBarTrack}>
|
{h.key_moment?.title ?? "Untitled highlight"}
|
||||||
<div
|
</span>
|
||||||
className={styles.scoreBarFill}
|
<span className={styles.candidateDuration}>
|
||||||
style={{ width: `${Math.round(h.score * 100)}%` }}
|
{formatDuration(h.duration_secs)}
|
||||||
/>
|
</span>
|
||||||
|
{h.duration_secs <= 60 && (
|
||||||
|
<span className={styles.shortsBadge}>Short</span>
|
||||||
|
)}
|
||||||
|
<span className={`${styles.badge} ${statusBadgeClass(h.status)}`}>
|
||||||
|
{h.status}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Score breakdown (shown when expanded) */}
|
{/* Composite score */}
|
||||||
{expandedId === h.id && h.score_breakdown && (
|
<div className={styles.compositeScore}>
|
||||||
<div className={styles.breakdownSection}>
|
<span className={styles.compositeLabel}>
|
||||||
{BREAKDOWN_LABELS.map(({ key, label }) => {
|
{Math.round(h.score * 100)}%
|
||||||
const val = h.score_breakdown![key];
|
</span>
|
||||||
return (
|
<div className={styles.scoreBarTrack}>
|
||||||
<div key={key} className={styles.breakdownRow}>
|
<div
|
||||||
<span className={styles.breakdownLabel}>{label}</span>
|
className={styles.scoreBarFill}
|
||||||
<div className={styles.breakdownBar}>
|
style={{ width: `${Math.round(h.score * 100)}%` }}
|
||||||
<div
|
/>
|
||||||
className={styles.breakdownBarFill}
|
</div>
|
||||||
style={{ width: `${Math.round(val * 100)}%` }}
|
</div>
|
||||||
/>
|
|
||||||
|
{/* Score breakdown (shown when expanded) */}
|
||||||
|
{expandedId === h.id && h.score_breakdown && (
|
||||||
|
<div className={styles.breakdownSection}>
|
||||||
|
{BREAKDOWN_LABELS.map(({ key, label }) => {
|
||||||
|
const val = h.score_breakdown![key];
|
||||||
|
return (
|
||||||
|
<div key={key} className={styles.breakdownRow}>
|
||||||
|
<span className={styles.breakdownLabel}>{label}</span>
|
||||||
|
<div className={styles.breakdownBar}>
|
||||||
|
<div
|
||||||
|
className={styles.breakdownBarFill}
|
||||||
|
style={{ width: `${Math.round(val * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.breakdownValue}>
|
||||||
|
{Math.round(val * 100)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.breakdownValue}>
|
);
|
||||||
{Math.round(val * 100)}%
|
})}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Shorts status badges */}
|
||||||
<div className={styles.actions}>
|
{showShortsStatus && (
|
||||||
<button
|
<div className={styles.shortsSection}>
|
||||||
className={`${styles.actionBtn} ${styles.approveBtn}`}
|
<span className={styles.shortsLabel}>Shorts</span>
|
||||||
disabled={actionLoading === h.id || h.status === "approved"}
|
<div className={styles.shortsBadges}>
|
||||||
onClick={() => handleApprove(h.id)}
|
{shorts.map((s) => (
|
||||||
>
|
<div key={s.id} className={styles.shortItem}>
|
||||||
Approve
|
<span
|
||||||
</button>
|
className={`${styles.shortBadge} ${shortStatusClass(s.status)}`}
|
||||||
<button
|
>
|
||||||
className={`${styles.actionBtn} ${styles.rejectBtn}`}
|
{formatPresetLabel(s.format_preset)}{" "}
|
||||||
disabled={actionLoading === h.id || h.status === "rejected"}
|
{s.status}
|
||||||
onClick={() => handleReject(h.id)}
|
</span>
|
||||||
>
|
{s.status === "complete" && (
|
||||||
Discard
|
<button
|
||||||
</button>
|
className={styles.downloadLink}
|
||||||
<button
|
onClick={() => handleDownload(s.id)}
|
||||||
className={styles.actionBtn}
|
>
|
||||||
disabled={actionLoading === h.id}
|
↓
|
||||||
onClick={() => handleToggleTrim(h)}
|
</button>
|
||||||
>
|
)}
|
||||||
{expandedId === h.id ? "Close" : "Trim"}
|
{s.status === "failed" && s.error_message && (
|
||||||
</button>
|
<span
|
||||||
|
className={styles.shortError}
|
||||||
|
title={s.error_message}
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.approveBtn}`}
|
||||||
|
disabled={actionLoading === h.id || h.status === "approved"}
|
||||||
|
onClick={() => handleApprove(h.id)}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.rejectBtn}`}
|
||||||
|
disabled={actionLoading === h.id || h.status === "rejected"}
|
||||||
|
onClick={() => handleReject(h.id)}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
disabled={actionLoading === h.id}
|
||||||
|
onClick={() => handleToggleTrim(h)}
|
||||||
|
>
|
||||||
|
{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 */}
|
||||||
|
{expandedId === h.id && (
|
||||||
|
<div className={styles.trimPanel}>
|
||||||
|
<div className={styles.trimField}>
|
||||||
|
<span className={styles.trimLabel}>Start (s)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.trimInput}
|
||||||
|
value={trimStart}
|
||||||
|
onChange={(e) => setTrimStart(e.target.value)}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trimField}>
|
||||||
|
<span className={styles.trimLabel}>End (s)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.trimInput}
|
||||||
|
value={trimEnd}
|
||||||
|
onChange={(e) => setTrimEnd(e.target.value)}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trimActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.approveBtn}`}
|
||||||
|
disabled={actionLoading === h.id}
|
||||||
|
onClick={() => handleSaveTrim(h.id)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={() => setExpandedId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{/* Trim panel */}
|
})}
|
||||||
{expandedId === h.id && (
|
|
||||||
<div className={styles.trimPanel}>
|
|
||||||
<div className={styles.trimField}>
|
|
||||||
<span className={styles.trimLabel}>Start (s)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.trimInput}
|
|
||||||
value={trimStart}
|
|
||||||
onChange={(e) => setTrimStart(e.target.value)}
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.trimField}>
|
|
||||||
<span className={styles.trimLabel}>End (s)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.trimInput}
|
|
||||||
value={trimEnd}
|
|
||||||
onChange={(e) => setTrimEnd(e.target.value)}
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.trimActions}>
|
|
||||||
<button
|
|
||||||
className={`${styles.actionBtn} ${styles.approveBtn}`}
|
|
||||||
disabled={actionLoading === h.id}
|
|
||||||
onClick={() => handleSaveTrim(h.id)}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={styles.actionBtn}
|
|
||||||
onClick={() => setExpandedId(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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/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"}
|
||||||
Loading…
Add table
Reference in a new issue