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
- 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')"
- [ ] **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:
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_highlights.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(highlights.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 ─────────────────────────────────────────────────
@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.
Creates one GeneratedShort row per FormatPreset, extracts the clip via
ffmpeg, uploads to MinIO, and updates status. Each preset is independent
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.
"""
from pipeline.shorts_generator import PRESETS, extract_clip_with_template, resolve_video_path
@ -2956,9 +2960,15 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str:
clip_start, clip_end,
)
# ── Generate captions from transcript (if available) ───────────
# ── Generate captions from transcript (if available and requested)
ass_path: Path | None = None
captions_ok = False
if not captions:
logger.info(
"Captions disabled for highlight=%s — skipping caption generation",
highlight_candidate_id,
)
else:
try:
transcript_data: list | None = None
if source_video.transcript_path:

View file

@ -1,7 +1,8 @@
"""Creator endpoints for Chrysopedia API.
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
@ -11,14 +12,24 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_role
from database import get_session
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage
from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage, User, UserRole
from schemas import (
CreatorBrowseItem,
CreatorDetail,
CreatorRead,
CreatorTechniqueItem,
ShortsTemplateConfig,
ShortsTemplateUpdate,
)
logger = logging.getLogger("chrysopedia.creators")
router = APIRouter(prefix="/creators", tags=["creators"])
_require_admin = require_role(UserRole.admin)
@router.get("")
async def list_creators(
@ -195,3 +206,93 @@ async def get_creator(
genre_breakdown=genre_breakdown,
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)
async def generate_shorts(
highlight_id: str,
captions: bool = True,
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.
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
stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id)
@ -104,7 +109,7 @@ async def generate_shorts(
from pipeline.stages import stage_generate_shorts
try:
task = stage_generate_shorts.delay(highlight_id)
task = stage_generate_shorts.delay(highlight_id, captions=captions)
logger.info(
"Shorts generation dispatched highlight_id=%s task_id=%s",
highlight_id,

View file

@ -817,3 +817,33 @@ class PostListResponse(BaseModel):
"""Paginated list of posts."""
items: list[PostRead] = Field(default_factory=list)
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 ────────────────────────────────────────────────────────────
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>(
`${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}`,
`${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}${params}`,
{ 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 {
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 { SidebarNav } from "./CreatorDashboard";
import { useAuth } from "../context/AuthContext";
import { ApiError } from "../api/client";
import {
fetchCreatorHighlights,
@ -15,6 +16,11 @@ import {
getShortDownloadUrl,
type GeneratedShort,
} from "../api/shorts";
import {
fetchShortsTemplate,
updateShortsTemplate,
type ShortsTemplateConfig,
} from "../api/templates";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
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 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) => {
setLoading(true);
setError(null);
@ -224,6 +249,34 @@ export default function HighlightQueue() {
};
}, [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) => {
setActiveTab(tab);
setExpandedId(null);
@ -306,8 +359,9 @@ export default function HighlightQueue() {
const handleGenerateShorts = async (highlightId: string) => {
setGeneratingIds((prev) => new Set(prev).add(highlightId));
setError(null);
const captions = captionsMap.get(highlightId) ?? true;
try {
await generateShorts(highlightId);
await generateShorts(highlightId, captions);
// Immediately fetch shorts to show pending status
const res = await fetchShorts(highlightId);
setShortsMap((prev) => {
@ -385,6 +439,149 @@ export default function HighlightQueue() {
))}
</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 && <div className={styles.errorState}>{error}</div>}
@ -547,6 +744,21 @@ export default function HighlightQueue() {
{expandedId === h.id ? "Close" : "Trim"}
</button>
{showGenerateBtn && (
<>
<label className={styles.captionToggle}>
<input
type="checkbox"
checked={captionsMap.get(h.id) ?? true}
onChange={(e) =>
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}
@ -554,6 +766,7 @@ export default function HighlightQueue() {
>
{isGenerating ? "Generating…" : "Generate Shorts"}
</button>
</>
)}
</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"}