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:
parent
fa493e2640
commit
a60f4074dc
13 changed files with 726 additions and 63 deletions
|
|
@ -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.
|
||||
|
|
|
|||
22
.gsd/milestones/M024/slices/S04/tasks/T02-VERIFY.json
Normal file
22
.gsd/milestones/M024/slices/S04/tasks/T02-VERIFY.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
93
.gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md
Normal file
93
.gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md
Normal 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.
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,55 +2960,61 @@ 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
|
||||
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,
|
||||
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:
|
||||
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) ─────────────────
|
||||
intro_path: Path | None = None
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
39
frontend/src/api/templates.ts
Normal file
39
frontend/src/api/templates.ts
Normal 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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 +744,29 @@ export default function HighlightQueue() {
|
|||
{expandedId === h.id ? "Close" : "Trim"}
|
||||
</button>
|
||||
{showGenerateBtn && (
|
||||
<button
|
||||
className={`${styles.actionBtn} ${styles.generateBtn}`}
|
||||
disabled={isGenerating}
|
||||
onClick={() => handleGenerateShorts(h.id)}
|
||||
>
|
||||
{isGenerating ? "Generating…" : "Generate Shorts"}
|
||||
</button>
|
||||
<>
|
||||
<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}
|
||||
onClick={() => handleGenerateShorts(h.id)}
|
||||
>
|
||||
{isGenerating ? "Generating…" : "Generate Shorts"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</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/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"}
|
||||
Loading…
Add table
Reference in a new issue