chore: Add GET/PUT shorts-template admin endpoints, collapsible templat…

- "backend/routers/creators.py"
- "backend/schemas.py"
- "frontend/src/api/templates.ts"
- "frontend/src/pages/HighlightQueue.tsx"
- "frontend/src/pages/HighlightQueue.module.css"
- "backend/routers/shorts.py"
- "backend/pipeline/stages.py"
- "frontend/src/api/shorts.ts"

GSD-Task: S04/T03
This commit is contained in:
jlightner 2026-04-04 11:25:29 +00:00
parent fa493e2640
commit a60f4074dc
13 changed files with 726 additions and 63 deletions

View file

@ -52,7 +52,7 @@ Steps:
- Estimate: 2h - Estimate: 2h
- Files: backend/pipeline/card_renderer.py, backend/pipeline/shorts_generator.py, backend/pipeline/stages.py, backend/models.py, alembic/versions/028_add_shorts_template.py, backend/pipeline/test_card_renderer.py - Files: backend/pipeline/card_renderer.py, backend/pipeline/shorts_generator.py, backend/pipeline/stages.py, backend/models.py, alembic/versions/028_add_shorts_template.py, backend/pipeline/test_card_renderer.py
- Verify: cd backend && python -m pytest pipeline/test_card_renderer.py -v && python -c "from pipeline.card_renderer import render_card, concat_segments; print('import ok')" - Verify: cd backend && python -m pytest pipeline/test_card_renderer.py -v && python -c "from pipeline.card_renderer import render_card, concat_segments; print('import ok')"
- [ ] **T03: Template API endpoints and frontend template config UI** — Add REST endpoints for reading and updating creator shorts template config. Add template configuration UI to the HighlightQueue page — color picker, text inputs, duration controls, and intro/outro toggles. Add a caption toggle to the short generation flow. - [x] **T03: Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task** — Add REST endpoints for reading and updating creator shorts template config. Add template configuration UI to the HighlightQueue page — color picker, text inputs, duration controls, and intro/outro toggles. Add a caption toggle to the short generation flow.
Steps: Steps:
1. Read T02 outputs to understand the shorts_template schema on the Creator model. 1. Read T02 outputs to understand the shorts_template schema on the Creator model.

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M024/S04/T02",
"timestamp": 1775301458773,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "python -m pytest pipeline/test_card_renderer.py -v",
"exitCode": 0,
"durationMs": 581,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,93 @@
---
id: T03
parent: S04
milestone: M024
provides: []
requires: []
affects: []
key_files: ["backend/routers/creators.py", "backend/schemas.py", "frontend/src/api/templates.ts", "frontend/src/pages/HighlightQueue.tsx", "frontend/src/pages/HighlightQueue.module.css", "backend/routers/shorts.py", "backend/pipeline/stages.py", "frontend/src/api/shorts.ts", "backend/main.py"]
key_decisions: ["Admin template endpoints on a separate admin_router in creators.py to keep public /creators endpoints untouched", "Template JSONB keys match card_renderer.parse_template_config expectations for zero-translation at pipeline read time"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Frontend builds cleanly (npm run build exit 0). Backend router imports succeed. All 45 prior tests (17 caption + 28 card renderer) still pass."
completed_at: 2026-04-04T11:25:15.695Z
blocker_discovered: false
---
# T03: Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task
> Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task
## What Happened
---
id: T03
parent: S04
milestone: M024
key_files:
- backend/routers/creators.py
- backend/schemas.py
- frontend/src/api/templates.ts
- frontend/src/pages/HighlightQueue.tsx
- frontend/src/pages/HighlightQueue.module.css
- backend/routers/shorts.py
- backend/pipeline/stages.py
- frontend/src/api/shorts.ts
- backend/main.py
key_decisions:
- Admin template endpoints on a separate admin_router in creators.py to keep public /creators endpoints untouched
- Template JSONB keys match card_renderer.parse_template_config expectations for zero-translation at pipeline read time
duration: ""
verification_result: passed
completed_at: 2026-04-04T11:25:15.695Z
blocker_discovered: false
---
# T03: Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task
**Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task**
## What Happened
Added ShortsTemplateConfig and ShortsTemplateUpdate Pydantic schemas with validation. Created admin_router in creators.py with GET/PUT endpoints under /admin/creators/{creator_id}/shorts-template requiring admin auth. Created frontend API client (templates.ts) and added collapsible template config panel to HighlightQueue with intro/outro text inputs, duration sliders, show/hide toggles, color picker, and font input. Added per-highlight captions toggle checkbox next to Generate Shorts button. Updated generate-shorts endpoint and Celery task to accept and respect the captions parameter.
## Verification
Frontend builds cleanly (npm run build exit 0). Backend router imports succeed. All 45 prior tests (17 caption + 28 card renderer) still pass.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 6500ms |
| 2 | `cd backend && python -c "from routers.creators import router, admin_router; print('ok')"` | 0 | ✅ pass | 6500ms |
| 3 | `cd backend && python -m pytest pipeline/test_caption_generator.py pipeline/test_card_renderer.py -v` | 0 | ✅ pass | 3700ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `backend/routers/creators.py`
- `backend/schemas.py`
- `frontend/src/api/templates.ts`
- `frontend/src/pages/HighlightQueue.tsx`
- `frontend/src/pages/HighlightQueue.module.css`
- `backend/routers/shorts.py`
- `backend/pipeline/stages.py`
- `frontend/src/api/shorts.ts`
- `backend/main.py`
## Deviations
None.
## Known Issues
None.

View file

@ -93,6 +93,7 @@ app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creator_chapters.router, prefix="/api/v1") app.include_router(creator_chapters.router, prefix="/api/v1")
app.include_router(creator_highlights.router, prefix="/api/v1") app.include_router(creator_highlights.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1")
app.include_router(creators.admin_router, prefix="/api/v1")
app.include_router(follows.router, prefix="/api/v1") app.include_router(follows.router, prefix="/api/v1")
app.include_router(highlights.router, prefix="/api/v1") app.include_router(highlights.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1")

View file

@ -2866,13 +2866,17 @@ def extract_personality_profile(self, creator_id: str) -> str:
# ── Stage: Shorts Generation ───────────────────────────────────────────────── # ── Stage: Shorts Generation ─────────────────────────────────────────────────
@celery_app.task(bind=True, max_retries=1, default_retry_delay=60) @celery_app.task(bind=True, max_retries=1, default_retry_delay=60)
def stage_generate_shorts(self, highlight_candidate_id: str) -> str: def stage_generate_shorts(self, highlight_candidate_id: str, captions: bool = True) -> str:
"""Generate video shorts for an approved highlight candidate. """Generate video shorts for an approved highlight candidate.
Creates one GeneratedShort row per FormatPreset, extracts the clip via Creates one GeneratedShort row per FormatPreset, extracts the clip via
ffmpeg, uploads to MinIO, and updates status. Each preset is independent ffmpeg, uploads to MinIO, and updates status. Each preset is independent
a failure on one does not block the others. a failure on one does not block the others.
Args:
highlight_candidate_id: UUID string of the approved highlight.
captions: Whether to generate and burn in ASS subtitles (default True).
Returns the highlight_candidate_id on completion. Returns the highlight_candidate_id on completion.
""" """
from pipeline.shorts_generator import PRESETS, extract_clip_with_template, resolve_video_path from pipeline.shorts_generator import PRESETS, extract_clip_with_template, resolve_video_path
@ -2956,55 +2960,61 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str:
clip_start, clip_end, clip_start, clip_end,
) )
# ── Generate captions from transcript (if available) ─────────── # ── Generate captions from transcript (if available and requested)
ass_path: Path | None = None ass_path: Path | None = None
captions_ok = False captions_ok = False
try: if not captions:
transcript_data: list | None = None logger.info(
if source_video.transcript_path: "Captions disabled for highlight=%s — skipping caption generation",
try: highlight_candidate_id,
with open(source_video.transcript_path, "r") as fh:
raw = json.load(fh)
if isinstance(raw, dict):
transcript_data = raw.get("segments", raw.get("results", []))
elif isinstance(raw, list):
transcript_data = raw
except (FileNotFoundError, json.JSONDecodeError, OSError) as io_exc:
logger.warning(
"Failed to load transcript for captions highlight=%s: %s",
highlight_candidate_id, io_exc,
)
if transcript_data:
from pipeline.highlight_scorer import extract_word_timings
word_timings = extract_word_timings(transcript_data, clip_start, clip_end)
if word_timings:
ass_content = generate_ass_captions(word_timings, clip_start)
ass_path = write_ass_file(
ass_content,
Path(f"/tmp/captions_{highlight_candidate_id}.ass"),
)
captions_ok = True
logger.info(
"Generated captions for highlight=%s (%d words)",
highlight_candidate_id, len(word_timings),
)
else:
logger.warning(
"No word timings in transcript window [%.1f%.1f]s for highlight=%s — proceeding without captions",
clip_start, clip_end, highlight_candidate_id,
)
else:
logger.info(
"No transcript available for highlight=%s — proceeding without captions",
highlight_candidate_id,
)
except Exception as cap_exc:
logger.warning(
"Caption generation failed for highlight=%s: %s — proceeding without captions",
highlight_candidate_id, cap_exc,
) )
else:
try:
transcript_data: list | None = None
if source_video.transcript_path:
try:
with open(source_video.transcript_path, "r") as fh:
raw = json.load(fh)
if isinstance(raw, dict):
transcript_data = raw.get("segments", raw.get("results", []))
elif isinstance(raw, list):
transcript_data = raw
except (FileNotFoundError, json.JSONDecodeError, OSError) as io_exc:
logger.warning(
"Failed to load transcript for captions highlight=%s: %s",
highlight_candidate_id, io_exc,
)
if transcript_data:
from pipeline.highlight_scorer import extract_word_timings
word_timings = extract_word_timings(transcript_data, clip_start, clip_end)
if word_timings:
ass_content = generate_ass_captions(word_timings, clip_start)
ass_path = write_ass_file(
ass_content,
Path(f"/tmp/captions_{highlight_candidate_id}.ass"),
)
captions_ok = True
logger.info(
"Generated captions for highlight=%s (%d words)",
highlight_candidate_id, len(word_timings),
)
else:
logger.warning(
"No word timings in transcript window [%.1f%.1f]s for highlight=%s — proceeding without captions",
clip_start, clip_end, highlight_candidate_id,
)
else:
logger.info(
"No transcript available for highlight=%s — proceeding without captions",
highlight_candidate_id,
)
except Exception as cap_exc:
logger.warning(
"Caption generation failed for highlight=%s: %s — proceeding without captions",
highlight_candidate_id, cap_exc,
)
# ── Load creator template config (if available) ───────────────── # ── Load creator template config (if available) ─────────────────
intro_path: Path | None = None intro_path: Path | None = None

View file

@ -1,7 +1,8 @@
"""Creator endpoints for Chrysopedia API. """Creator endpoints for Chrysopedia API.
Enhanced with sort (random default per R014), genre filter, and Enhanced with sort (random default per R014), genre filter, and
technique/video counts for browse pages. technique/video counts for browse pages. Includes admin endpoints
for shorts template configuration.
""" """
import logging import logging
@ -11,14 +12,24 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_role
from database import get_session from database import get_session
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage, User, UserRole
from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem from schemas import (
CreatorBrowseItem,
CreatorDetail,
CreatorRead,
CreatorTechniqueItem,
ShortsTemplateConfig,
ShortsTemplateUpdate,
)
logger = logging.getLogger("chrysopedia.creators") logger = logging.getLogger("chrysopedia.creators")
router = APIRouter(prefix="/creators", tags=["creators"]) router = APIRouter(prefix="/creators", tags=["creators"])
_require_admin = require_role(UserRole.admin)
@router.get("") @router.get("")
async def list_creators( async def list_creators(
@ -195,3 +206,93 @@ async def get_creator(
genre_breakdown=genre_breakdown, genre_breakdown=genre_breakdown,
follower_count=follower_count, follower_count=follower_count,
) )
# ── Admin: Shorts Template Config ────────────────────────────────────────────
admin_router = APIRouter(prefix="/admin/creators", tags=["admin-creators"])
@admin_router.get(
"/{creator_id}/shorts-template",
response_model=ShortsTemplateConfig,
)
async def get_shorts_template(
creator_id: str,
_admin: Annotated[User, Depends(_require_admin)],
db: AsyncSession = Depends(get_session),
) -> ShortsTemplateConfig:
"""Return the current shorts template config for a creator.
Returns default values when the creator has no template set.
"""
stmt = select(Creator).where(Creator.id == creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator is None:
raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}")
raw = creator.shorts_template or {}
return ShortsTemplateConfig(
show_intro=bool(raw.get("show_intro", False)),
intro_text=str(raw.get("intro_text", "")),
intro_duration_secs=float(raw.get("intro_duration", 2.0)),
show_outro=bool(raw.get("show_outro", False)),
outro_text=str(raw.get("outro_text", "")),
outro_duration_secs=float(raw.get("outro_duration", 2.0)),
accent_color=str(raw.get("accent_color", "#22d3ee")),
font_family=str(raw.get("font_family", "Inter")),
)
@admin_router.put(
"/{creator_id}/shorts-template",
response_model=ShortsTemplateConfig,
)
async def update_shorts_template(
creator_id: str,
body: ShortsTemplateUpdate,
_admin: Annotated[User, Depends(_require_admin)],
db: AsyncSession = Depends(get_session),
) -> ShortsTemplateConfig:
"""Save shorts template config for a creator.
Validates all fields and stores as JSONB on the Creator row.
"""
stmt = select(Creator).where(Creator.id == creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator is None:
raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}")
# Store using the keys that card_renderer.parse_template_config expects
creator.shorts_template = {
"show_intro": body.show_intro,
"intro_text": body.intro_text,
"intro_duration": body.intro_duration_secs,
"show_outro": body.show_outro,
"outro_text": body.outro_text,
"outro_duration": body.outro_duration_secs,
"accent_color": body.accent_color,
"font_family": body.font_family,
}
await db.commit()
await db.refresh(creator)
logger.info(
"Updated shorts template for creator_id=%s (intro=%s, outro=%s)",
creator_id, body.show_intro, body.show_outro,
)
return ShortsTemplateConfig(
show_intro=body.show_intro,
intro_text=body.intro_text,
intro_duration_secs=body.intro_duration_secs,
show_outro=body.show_outro,
outro_text=body.outro_text,
outro_duration_secs=body.outro_duration_secs,
accent_color=body.accent_color,
font_family=body.font_family,
)

View file

@ -62,12 +62,17 @@ class GenerateResponse(BaseModel):
@router.post("/generate/{highlight_id}", response_model=GenerateResponse, status_code=202) @router.post("/generate/{highlight_id}", response_model=GenerateResponse, status_code=202)
async def generate_shorts( async def generate_shorts(
highlight_id: str, highlight_id: str,
captions: bool = True,
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
): ):
"""Dispatch shorts generation for an approved highlight candidate. """Dispatch shorts generation for an approved highlight candidate.
Creates pending GeneratedShort rows for each format preset and dispatches Creates pending GeneratedShort rows for each format preset and dispatches
the Celery task. Returns 202 Accepted with status. the Celery task. Returns 202 Accepted with status.
Args:
highlight_id: UUID of the approved highlight candidate.
captions: Whether to burn in Whisper-generated subtitles (default True).
""" """
# Validate highlight exists and is approved # Validate highlight exists and is approved
stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id) stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id)
@ -104,7 +109,7 @@ async def generate_shorts(
from pipeline.stages import stage_generate_shorts from pipeline.stages import stage_generate_shorts
try: try:
task = stage_generate_shorts.delay(highlight_id) task = stage_generate_shorts.delay(highlight_id, captions=captions)
logger.info( logger.info(
"Shorts generation dispatched highlight_id=%s task_id=%s", "Shorts generation dispatched highlight_id=%s task_id=%s",
highlight_id, highlight_id,

View file

@ -817,3 +817,33 @@ class PostListResponse(BaseModel):
"""Paginated list of posts.""" """Paginated list of posts."""
items: list[PostRead] = Field(default_factory=list) items: list[PostRead] = Field(default_factory=list)
total: int = 0 total: int = 0
# ── Shorts Template ──────────────────────────────────────────────────────────
class ShortsTemplateConfig(BaseModel):
"""Read schema for creator shorts template configuration."""
show_intro: bool = False
intro_text: str = ""
intro_duration_secs: float = 2.0
show_outro: bool = False
outro_text: str = ""
outro_duration_secs: float = 2.0
accent_color: str = "#22d3ee"
font_family: str = "Inter"
class ShortsTemplateUpdate(BaseModel):
"""Update payload for creator shorts template configuration."""
intro_text: str = Field(default="", max_length=100)
outro_text: str = Field(default="", max_length=100)
accent_color: str = Field(
default="#22d3ee",
pattern=r"^#[0-9a-fA-F]{6}$",
description="Hex color code, e.g. #22d3ee",
)
font_family: str = Field(default="Inter", max_length=50)
intro_duration_secs: float = Field(default=2.0, ge=1.0, le=5.0)
outro_duration_secs: float = Field(default=2.0, ge=1.0, le=5.0)
show_intro: bool = False
show_outro: bool = False

View file

@ -42,9 +42,13 @@ export interface DownloadResponse {
// ── API functions ──────────────────────────────────────────────────────────── // ── API functions ────────────────────────────────────────────────────────────
export function generateShorts(highlightId: string): Promise<GenerateResponse> { export function generateShorts(
highlightId: string,
captions: boolean = true,
): Promise<GenerateResponse> {
const params = captions ? "" : "?captions=false";
return request<GenerateResponse>( return request<GenerateResponse>(
`${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}`, `${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}${params}`,
{ method: "POST" }, { method: "POST" },
); );
} }

View file

@ -0,0 +1,39 @@
import { request, BASE } from "./client";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ShortsTemplateConfig {
show_intro: boolean;
intro_text: string;
intro_duration_secs: number;
show_outro: boolean;
outro_text: string;
outro_duration_secs: number;
accent_color: string;
font_family: string;
}
export type ShortsTemplateUpdate = ShortsTemplateConfig;
// ── API functions ────────────────────────────────────────────────────────────
export function fetchShortsTemplate(
creatorId: string,
): Promise<ShortsTemplateConfig> {
return request<ShortsTemplateConfig>(
`${BASE}/admin/creators/${encodeURIComponent(creatorId)}/shorts-template`,
);
}
export function updateShortsTemplate(
creatorId: string,
config: ShortsTemplateUpdate,
): Promise<ShortsTemplateConfig> {
return request<ShortsTemplateConfig>(
`${BASE}/admin/creators/${encodeURIComponent(creatorId)}/shorts-template`,
{
method: "PUT",
body: JSON.stringify(config),
},
);
}

View file

@ -489,4 +489,149 @@
.trimActions { .trimActions {
margin-left: 0; margin-left: 0;
} }
.templateGrid {
grid-template-columns: 1fr;
}
}
/* ── Template config section ───────────────────────────────────────────────── */
.templateSection {
margin-bottom: 1.25rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-surface);
overflow: hidden;
}
.templateToggle {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-secondary);
background: none;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.templateToggle:hover {
background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.03));
}
.templateChevron {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.templateBody {
padding: 0 1rem 1rem;
}
.templateGrid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
margin-bottom: 0.75rem;
}
.templateGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.templateLabel {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
}
.templateLabel input[type="checkbox"] {
accent-color: var(--color-accent, #00ffd1);
}
.templateInput {
width: 100%;
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-surface);
color: var(--color-text-primary);
box-sizing: border-box;
}
.templateInput:focus {
outline: none;
border-color: var(--color-accent);
}
.templateInput:disabled {
opacity: 0.4;
}
.templateRangeLabel {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-muted);
}
.templateRangeLabel input[type="range"] {
width: 100%;
accent-color: var(--color-accent, #00ffd1);
}
.templateRangeLabel input[type="range"]:disabled {
opacity: 0.4;
}
.colorInput {
width: 2rem;
height: 1.5rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
background: none;
}
.templateActions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.templateMsg {
font-size: 0.75rem;
color: var(--color-badge-approved-text, #22c55e);
}
/* ── Captions toggle ──────────────────────────────────────────────────────── */
.captionToggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
}
.captionToggle input[type="checkbox"] {
accent-color: var(--color-accent, #00ffd1);
} }

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { SidebarNav } from "./CreatorDashboard"; import { SidebarNav } from "./CreatorDashboard";
import { useAuth } from "../context/AuthContext";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { import {
fetchCreatorHighlights, fetchCreatorHighlights,
@ -15,6 +16,11 @@ import {
getShortDownloadUrl, getShortDownloadUrl,
type GeneratedShort, type GeneratedShort,
} from "../api/shorts"; } from "../api/shorts";
import {
fetchShortsTemplate,
updateShortsTemplate,
type ShortsTemplateConfig,
} from "../api/templates";
import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./HighlightQueue.module.css"; import styles from "./HighlightQueue.module.css";
@ -129,6 +135,25 @@ export default function HighlightQueue() {
const [copiedShort, setCopiedShort] = useState<{ id: string; kind: "share" | "embed" } | null>(null); const [copiedShort, setCopiedShort] = useState<{ id: string; kind: "share" | "embed" } | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Template config state
const { user } = useAuth();
const [templateOpen, setTemplateOpen] = useState(false);
const [template, setTemplate] = useState<ShortsTemplateConfig>({
show_intro: false,
intro_text: "",
intro_duration_secs: 2.0,
show_outro: false,
outro_text: "",
outro_duration_secs: 2.0,
accent_color: "#22d3ee",
font_family: "Inter",
});
const [templateSaving, setTemplateSaving] = useState(false);
const [templateMsg, setTemplateMsg] = useState<string | null>(null);
// Per-highlight captions toggle: default true
const [captionsMap, setCaptionsMap] = useState<Map<string, boolean>>(new Map());
const loadHighlights = useCallback(async (tab: FilterTab) => { const loadHighlights = useCallback(async (tab: FilterTab) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -224,6 +249,34 @@ export default function HighlightQueue() {
}; };
}, [shortsMap]); }, [shortsMap]);
// Load template config on mount if the user has a creator_id
useEffect(() => {
if (!user?.creator_id) return;
fetchShortsTemplate(user.creator_id)
.then(setTemplate)
.catch(() => {
// Non-critical — keep defaults
});
}, [user?.creator_id]);
const handleSaveTemplate = async () => {
if (!user?.creator_id) return;
setTemplateSaving(true);
setTemplateMsg(null);
try {
const saved = await updateShortsTemplate(user.creator_id, template);
setTemplate(saved);
setTemplateMsg("Template saved");
setTimeout(() => setTemplateMsg(null), 2000);
} catch (err) {
setTemplateMsg(
err instanceof ApiError ? err.detail : "Failed to save template",
);
} finally {
setTemplateSaving(false);
}
};
const handleTabChange = (tab: FilterTab) => { const handleTabChange = (tab: FilterTab) => {
setActiveTab(tab); setActiveTab(tab);
setExpandedId(null); setExpandedId(null);
@ -306,8 +359,9 @@ export default function HighlightQueue() {
const handleGenerateShorts = async (highlightId: string) => { const handleGenerateShorts = async (highlightId: string) => {
setGeneratingIds((prev) => new Set(prev).add(highlightId)); setGeneratingIds((prev) => new Set(prev).add(highlightId));
setError(null); setError(null);
const captions = captionsMap.get(highlightId) ?? true;
try { try {
await generateShorts(highlightId); await generateShorts(highlightId, captions);
// Immediately fetch shorts to show pending status // Immediately fetch shorts to show pending status
const res = await fetchShorts(highlightId); const res = await fetchShorts(highlightId);
setShortsMap((prev) => { setShortsMap((prev) => {
@ -385,6 +439,149 @@ export default function HighlightQueue() {
))} ))}
</div> </div>
{/* Template config collapsible section */}
{user?.creator_id && (
<div className={styles.templateSection}>
<button
className={styles.templateToggle}
onClick={() => setTemplateOpen((v) => !v)}
>
<span>Shorts Template</span>
<span className={styles.templateChevron}>
{templateOpen ? "▾" : "▸"}
</span>
</button>
{templateOpen && (
<div className={styles.templateBody}>
<div className={styles.templateGrid}>
{/* Intro */}
<div className={styles.templateGroup}>
<label className={styles.templateLabel}>
<input
type="checkbox"
checked={template.show_intro}
onChange={(e) =>
setTemplate((t) => ({ ...t, show_intro: e.target.checked }))
}
/>
Show Intro
</label>
<input
type="text"
className={styles.templateInput}
placeholder="Intro text"
maxLength={100}
value={template.intro_text}
onChange={(e) =>
setTemplate((t) => ({ ...t, intro_text: e.target.value }))
}
disabled={!template.show_intro}
/>
<label className={styles.templateRangeLabel}>
Duration: {template.intro_duration_secs.toFixed(1)}s
<input
type="range"
min={1}
max={5}
step={0.5}
value={template.intro_duration_secs}
onChange={(e) =>
setTemplate((t) => ({
...t,
intro_duration_secs: parseFloat(e.target.value),
}))
}
disabled={!template.show_intro}
/>
</label>
</div>
{/* Outro */}
<div className={styles.templateGroup}>
<label className={styles.templateLabel}>
<input
type="checkbox"
checked={template.show_outro}
onChange={(e) =>
setTemplate((t) => ({ ...t, show_outro: e.target.checked }))
}
/>
Show Outro
</label>
<input
type="text"
className={styles.templateInput}
placeholder="Outro text"
maxLength={100}
value={template.outro_text}
onChange={(e) =>
setTemplate((t) => ({ ...t, outro_text: e.target.value }))
}
disabled={!template.show_outro}
/>
<label className={styles.templateRangeLabel}>
Duration: {template.outro_duration_secs.toFixed(1)}s
<input
type="range"
min={1}
max={5}
step={0.5}
value={template.outro_duration_secs}
onChange={(e) =>
setTemplate((t) => ({
...t,
outro_duration_secs: parseFloat(e.target.value),
}))
}
disabled={!template.show_outro}
/>
</label>
</div>
{/* Style */}
<div className={styles.templateGroup}>
<label className={styles.templateRangeLabel}>
Accent Color
<input
type="color"
value={template.accent_color}
onChange={(e) =>
setTemplate((t) => ({ ...t, accent_color: e.target.value }))
}
className={styles.colorInput}
/>
</label>
<label className={styles.templateRangeLabel}>
Font
<input
type="text"
className={styles.templateInput}
value={template.font_family}
onChange={(e) =>
setTemplate((t) => ({ ...t, font_family: e.target.value }))
}
maxLength={50}
/>
</label>
</div>
</div>
<div className={styles.templateActions}>
<button
className={`${styles.actionBtn} ${styles.approveBtn}`}
onClick={handleSaveTemplate}
disabled={templateSaving}
>
{templateSaving ? "Saving…" : "Save Template"}
</button>
{templateMsg && (
<span className={styles.templateMsg}>{templateMsg}</span>
)}
</div>
</div>
)}
</div>
)}
{/* Error */} {/* Error */}
{error && <div className={styles.errorState}>{error}</div>} {error && <div className={styles.errorState}>{error}</div>}
@ -547,13 +744,29 @@ export default function HighlightQueue() {
{expandedId === h.id ? "Close" : "Trim"} {expandedId === h.id ? "Close" : "Trim"}
</button> </button>
{showGenerateBtn && ( {showGenerateBtn && (
<button <>
className={`${styles.actionBtn} ${styles.generateBtn}`} <label className={styles.captionToggle}>
disabled={isGenerating} <input
onClick={() => handleGenerateShorts(h.id)} type="checkbox"
> checked={captionsMap.get(h.id) ?? true}
{isGenerating ? "Generating…" : "Generate Shorts"} onChange={(e) =>
</button> setCaptionsMap((prev) => {
const next = new Map(prev);
next.set(h.id, e.target.checked);
return next;
})
}
/>
Captions
</label>
<button
className={`${styles.actionBtn} ${styles.generateBtn}`}
disabled={isGenerating}
onClick={() => handleGenerateShorts(h.id)}
>
{isGenerating ? "Generating…" : "Generate Shorts"}
</button>
</>
)} )}
</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/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/EmbedPlayer.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/ShortPlayer.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","./src/utils/clipboard.ts"],"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/templates.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/EmbedPlayer.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/ShortPlayer.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","./src/utils/clipboard.ts"],"version":"5.6.3"}