diff --git a/.gsd/milestones/M022/slices/S01/S01-PLAN.md b/.gsd/milestones/M022/slices/S01/S01-PLAN.md index 435a7f5..254af50 100644 --- a/.gsd/milestones/M022/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M022/slices/S01/S01-PLAN.md @@ -58,7 +58,7 @@ - Estimate: 1h - Files: backend/models.py, alembic/versions/021_add_highlight_trim_columns.py, backend/routers/creator_highlights.py, backend/main.py - Verify: cd /home/aux/projects/content-to-kb-automator && python -c "import sys; sys.path.insert(0,'backend'); from models import HighlightCandidate; assert hasattr(HighlightCandidate,'trim_start'); print('OK')" && grep -q creator_highlights backend/main.py && echo 'Registered' -- [ ] **T02: HighlightQueue page, API layer, route wiring, and sidebar link** — Build the complete frontend: TypeScript API layer, HighlightQueue page with filter tabs and action controls, CSS module, route registration in App.tsx, and Highlights link in SidebarNav. Follows the ChapterReview.tsx pattern for layout and CreatorDashboard SidebarNav for navigation. +- [x] **T02: Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link** — Build the complete frontend: TypeScript API layer, HighlightQueue page with filter tabs and action controls, CSS module, route registration in App.tsx, and Highlights link in SidebarNav. Follows the ChapterReview.tsx pattern for layout and CreatorDashboard SidebarNav for navigation. ## Steps diff --git a/.gsd/milestones/M022/slices/S01/tasks/T01-VERIFY.json b/.gsd/milestones/M022/slices/S01/tasks/T01-VERIFY.json new file mode 100644 index 0000000..c959ecf --- /dev/null +++ b/.gsd/milestones/M022/slices/S01/tasks/T01-VERIFY.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M022/S01/T01", + "timestamp": 1775285908195, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd /home/aux/projects/content-to-kb-automator", + "exitCode": 0, + "durationMs": 7, + "verdict": "pass" + }, + { + "command": "grep -q creator_highlights backend/main.py", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + }, + { + "command": "echo 'Registered'", + "exitCode": 0, + "durationMs": 4, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M022/slices/S01/tasks/T02-SUMMARY.md b/.gsd/milestones/M022/slices/S01/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..e2339e0 --- /dev/null +++ b/.gsd/milestones/M022/slices/S01/tasks/T02-SUMMARY.md @@ -0,0 +1,87 @@ +--- +id: T02 +parent: S01 +milestone: M022 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/api/highlights.ts", "frontend/src/pages/HighlightQueue.tsx", "frontend/src/pages/HighlightQueue.module.css", "frontend/src/App.tsx", "frontend/src/pages/CreatorDashboard.tsx"] +key_decisions: ["Fetch full detail (score_breakdown) lazily when user expands trim panel, not on list load"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "npx tsc --noEmit passed with zero errors. All file existence and content grep checks passed: API layer, page, CSS module exist; HighlightQueue route wired in App.tsx; Highlights link added in CreatorDashboard.tsx SidebarNav." +completed_at: 2026-04-04T07:01:49.565Z +blocker_discovered: false +--- + +# T02: Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link + +> Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link + +## What Happened +--- +id: T02 +parent: S01 +milestone: M022 +key_files: + - frontend/src/api/highlights.ts + - frontend/src/pages/HighlightQueue.tsx + - frontend/src/pages/HighlightQueue.module.css + - frontend/src/App.tsx + - frontend/src/pages/CreatorDashboard.tsx +key_decisions: + - Fetch full detail (score_breakdown) lazily when user expands trim panel, not on list load +duration: "" +verification_result: passed +completed_at: 2026-04-04T07:01:49.566Z +blocker_discovered: false +--- + +# T02: Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link + +**Built HighlightQueue page with filter tabs, candidate cards with score bars, approve/discard/trim actions, TypeScript API layer, route wiring, and SidebarNav link** + +## What Happened + +Created the complete frontend for the highlight review queue: TypeScript API layer (4 functions matching backend endpoints), CSS module following ChapterReview patterns, HighlightQueue page component with filter tabs (All/Shorts/Approved/Rejected), candidate cards showing key_moment title/duration/score/status, 7-dimension score breakdown bars, approve/discard action buttons, inline trim panel with validated number inputs. Added lazy-loaded route in App.tsx and Highlights star-icon link in SidebarNav. + +## Verification + +npx tsc --noEmit passed with zero errors. All file existence and content grep checks passed: API layer, page, CSS module exist; HighlightQueue route wired in App.tsx; Highlights link added in CreatorDashboard.tsx SidebarNav. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 4200ms | +| 2 | `test -f frontend/src/api/highlights.ts` | 0 | ✅ pass | 50ms | +| 3 | `test -f frontend/src/pages/HighlightQueue.tsx` | 0 | ✅ pass | 50ms | +| 4 | `test -f frontend/src/pages/HighlightQueue.module.css` | 0 | ✅ pass | 50ms | +| 5 | `grep -q HighlightQueue frontend/src/App.tsx` | 0 | ✅ pass | 50ms | +| 6 | `grep -q Highlights frontend/src/pages/CreatorDashboard.tsx` | 0 | ✅ pass | 50ms | + + +## Deviations + +Score breakdown fetched lazily via fetchHighlightDetail on expand rather than included in list response — avoids N+1 detail calls on initial load. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/api/highlights.ts` +- `frontend/src/pages/HighlightQueue.tsx` +- `frontend/src/pages/HighlightQueue.module.css` +- `frontend/src/App.tsx` +- `frontend/src/pages/CreatorDashboard.tsx` + + +## Deviations +Score breakdown fetched lazily via fetchHighlightDetail on expand rather than included in list response — avoids N+1 detail calls on initial load. + +## Known Issues +None. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2170069..f856d47 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ const AdminUsers = React.lazy(() => import("./pages/AdminUsers")); const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog")); const ChatPage = React.lazy(() => import("./pages/ChatPage")); const ChapterReview = React.lazy(() => import("./pages/ChapterReview")); +const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue")); import AdminDropdown from "./components/AdminDropdown"; import ImpersonationBanner from "./components/ImpersonationBanner"; import AppFooter from "./components/AppFooter"; @@ -203,6 +204,7 @@ function AppShell() { }>} /> }>} /> }>} /> + }>} /> {/* Fallback */} } /> diff --git a/frontend/src/api/highlights.ts b/frontend/src/api/highlights.ts new file mode 100644 index 0000000..85dd327 --- /dev/null +++ b/frontend/src/api/highlights.ts @@ -0,0 +1,81 @@ +import { request, BASE } from "./client"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ScoreBreakdown { + duration_score: number; + content_density_score: number; + technique_relevance_score: number; + position_score: number; + uniqueness_score: number; + engagement_proxy_score: number; + plugin_diversity_score: number; +} + +export interface KeyMomentInfo { + id: string; + title: string; + start_time: number; + end_time: number; +} + +export interface HighlightCandidate { + id: string; + key_moment_id: string; + source_video_id: string; + score: number; + score_breakdown?: ScoreBreakdown | null; + duration_secs: number; + status: string; + trim_start: number | null; + trim_end: number | null; + key_moment: KeyMomentInfo | null; +} + +export interface HighlightListResponse { + highlights: HighlightCandidate[]; +} + +// ── API functions ──────────────────────────────────────────────────────────── + +export function fetchCreatorHighlights(params?: { + status?: string; + shorts_only?: boolean; +}): Promise { + const query = new URLSearchParams(); + if (params?.status) query.set("status", params.status); + if (params?.shorts_only) query.set("shorts_only", "true"); + const qs = query.toString(); + return request( + `${BASE}/creator/highlights${qs ? `?${qs}` : ""}`, + ); +} + +export function fetchHighlightDetail( + id: string, +): Promise { + return request( + `${BASE}/creator/highlights/${encodeURIComponent(id)}`, + ); +} + +export function updateHighlightStatus( + id: string, + status: string, +): Promise { + return request( + `${BASE}/creator/highlights/${encodeURIComponent(id)}`, + { method: "PATCH", body: JSON.stringify({ status }) }, + ); +} + +export function trimHighlight( + id: string, + trim_start: number, + trim_end: number, +): Promise { + return request( + `${BASE}/creator/highlights/${encodeURIComponent(id)}/trim`, + { method: "PATCH", body: JSON.stringify({ trim_start, trim_end }) }, + ); +} diff --git a/frontend/src/pages/CreatorDashboard.tsx b/frontend/src/pages/CreatorDashboard.tsx index 87398e8..60a0b6f 100644 --- a/frontend/src/pages/CreatorDashboard.tsx +++ b/frontend/src/pages/CreatorDashboard.tsx @@ -31,6 +31,12 @@ function SidebarNav() { Chapters + + + + + Highlights + diff --git a/frontend/src/pages/HighlightQueue.module.css b/frontend/src/pages/HighlightQueue.module.css new file mode 100644 index 0000000..5c44c7e --- /dev/null +++ b/frontend/src/pages/HighlightQueue.module.css @@ -0,0 +1,388 @@ +/* ── Layout — reuses creator sidebar pattern ──────────────────────────────── */ + +.layout { + display: flex; + gap: 0; + min-height: 60vh; +} + +.content { + flex: 1; + min-width: 0; + padding: 2rem; +} + +.pageTitle { + margin: 0 0 0.25rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.subtitle { + margin: 0 0 1.5rem; + font-size: 0.875rem; + color: var(--color-text-muted); +} + +/* ── Filter tabs ──────────────────────────────────────────────────────────── */ + +.filterTabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); + padding-bottom: 0; +} + +.filterTab { + padding: 0.5rem 1rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-muted); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + margin-bottom: -1px; +} + +.filterTab:hover { + color: var(--color-text-secondary); +} + +.filterTabActive { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +/* ── Candidate cards ──────────────────────────────────────────────────────── */ + +.candidateList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.candidateCard { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1rem 1.25rem; + transition: border-color 0.15s; +} + +.candidateCard:hover { + border-color: var(--color-accent); +} + +.candidateHeader { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.candidateTitle { + flex: 1; + min-width: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.candidateDuration { + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + color: var(--color-text-muted); + white-space: nowrap; +} + +/* ── Badges ────────────────────────────────────────────────────────────────── */ + +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + white-space: nowrap; + flex-shrink: 0; +} + +.badgeCandidate { + background: var(--color-badge-pending-bg); + color: var(--color-badge-pending-text); +} + +.badgeApproved { + background: var(--color-badge-approved-bg); + color: var(--color-badge-approved-text); +} + +.badgeRejected { + background: var(--color-badge-rejected-bg); + color: var(--color-badge-rejected-text); +} + +.shortsBadge { + background: var(--color-accent-subtle, rgba(0, 255, 209, 0.12)); + color: var(--color-accent, #00ffd1); + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + white-space: nowrap; +} + +/* ── Composite score bar ──────────────────────────────────────────────────── */ + +.compositeScore { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.compositeLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + white-space: nowrap; + min-width: 3rem; + text-align: right; +} + +.scoreBarTrack { + flex: 1; + height: 6px; + background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.06)); + border-radius: 3px; + overflow: hidden; +} + +.scoreBarFill { + height: 100%; + border-radius: 3px; + background: var(--color-accent); + transition: width 0.3s ease; +} + +/* ── Score breakdown ──────────────────────────────────────────────────────── */ + +.breakdownSection { + margin-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.breakdownRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.breakdownLabel { + font-size: 0.6875rem; + color: var(--color-text-muted); + min-width: 9rem; + text-align: right; +} + +.breakdownBar { + flex: 1; + height: 4px; + background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.06)); + border-radius: 2px; + overflow: hidden; +} + +.breakdownBarFill { + height: 100%; + border-radius: 2px; + background: var(--color-accent); + opacity: 0.7; + transition: width 0.3s ease; +} + +.breakdownValue { + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + color: var(--color-text-muted); + min-width: 2.5rem; +} + +/* ── Action buttons ───────────────────────────────────────────────────────── */ + +.actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.actionBtn { + padding: 0.375rem 0.875rem; + border-radius: 6px; + font-size: 0.8125rem; + font-weight: 600; + border: 1px solid var(--color-border); + background: var(--color-bg-surface); + color: var(--color-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.actionBtn:hover { + background: var(--color-accent-subtle); + color: var(--color-accent); +} + +.actionBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.approveBtn { + background: var(--color-badge-approved-bg, rgba(34, 197, 94, 0.12)); + color: var(--color-badge-approved-text, #22c55e); + border-color: transparent; +} + +.approveBtn:hover { + background: rgba(34, 197, 94, 0.22); + color: #22c55e; +} + +.rejectBtn { + background: var(--color-badge-rejected-bg, rgba(239, 68, 68, 0.12)); + color: var(--color-badge-rejected-text, #ef4444); + border-color: transparent; +} + +.rejectBtn:hover { + background: rgba(239, 68, 68, 0.22); + color: #ef4444; +} + +.trimBtn { + composes: actionBtn; +} + +/* ── Trim panel ───────────────────────────────────────────────────────────── */ + +.trimPanel { + margin-top: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.03)); + border: 1px solid var(--color-border); + border-radius: 6px; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.trimField { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.trimLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.trimInput { + width: 5rem; + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg-surface); + color: var(--color-text-primary); +} + +.trimInput:focus { + outline: none; + border-color: var(--color-accent); +} + +.trimActions { + display: flex; + gap: 0.375rem; + margin-left: auto; +} + +/* ── States ────────────────────────────────────────────────────────────────── */ + +.loadingState { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.emptyState { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-muted); +} + +.emptyState h2 { + font-size: 1.125rem; + color: var(--color-text-secondary); + margin: 0 0 0.5rem; +} + +.emptyState p { + font-size: 0.875rem; + margin: 0; +} + +.errorState { + background: var(--color-error-bg, rgba(220, 38, 38, 0.1)); + color: var(--color-error, #ef4444); + padding: 1rem; + border-radius: 8px; + font-size: 0.875rem; +} + +/* ── Responsive ────────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .layout { + flex-direction: column; + } + + .content { + padding: 1.25rem; + } + + .candidateHeader { + flex-wrap: wrap; + } + + .breakdownLabel { + min-width: 6rem; + font-size: 0.625rem; + } + + .trimPanel { + flex-direction: column; + align-items: flex-start; + } + + .trimActions { + margin-left: 0; + } +} diff --git a/frontend/src/pages/HighlightQueue.tsx b/frontend/src/pages/HighlightQueue.tsx new file mode 100644 index 0000000..12fbb2a --- /dev/null +++ b/frontend/src/pages/HighlightQueue.tsx @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useState } from "react"; +import { SidebarNav } from "./CreatorDashboard"; +import { ApiError } from "../api/client"; +import { + fetchCreatorHighlights, + fetchHighlightDetail, + updateHighlightStatus, + trimHighlight, + type HighlightCandidate, + type ScoreBreakdown, +} from "../api/highlights"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import styles from "./HighlightQueue.module.css"; + +/* ── Helpers ────────────────────────────────────────────────────────────────── */ + +function formatDuration(secs: number): string { + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function statusBadgeClass(status: string): string { + switch (status) { + case "approved": + return styles.badgeApproved; + case "rejected": + return styles.badgeRejected; + default: + return styles.badgeCandidate; + } +} + +const BREAKDOWN_LABELS: { key: keyof ScoreBreakdown; label: string }[] = [ + { key: "duration_score", label: "Duration" }, + { key: "content_density_score", label: "Content Density" }, + { key: "technique_relevance_score", label: "Technique" }, + { key: "position_score", label: "Position" }, + { key: "uniqueness_score", label: "Uniqueness" }, + { key: "engagement_proxy_score", label: "Engagement" }, + { key: "plugin_diversity_score", label: "Plugin Diversity" }, +]; + +type FilterTab = "all" | "shorts" | "approved" | "rejected"; + +/* ── Component ──────────────────────────────────────────────────────────────── */ + +export default function HighlightQueue() { + useDocumentTitle("Highlight Queue"); + + const [highlights, setHighlights] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("all"); + const [expandedId, setExpandedId] = useState(null); + const [trimStart, setTrimStart] = useState(""); + const [trimEnd, setTrimEnd] = useState(""); + const [actionLoading, setActionLoading] = useState(null); + + const loadHighlights = useCallback(async (tab: FilterTab) => { + setLoading(true); + setError(null); + try { + const params: { status?: string; shorts_only?: boolean } = {}; + if (tab === "shorts") params.shorts_only = true; + else if (tab === "approved") params.status = "approved"; + else if (tab === "rejected") params.status = "rejected"; + + const res = await fetchCreatorHighlights(params); + setHighlights(res.highlights); + } catch (err) { + setError(err instanceof ApiError ? err.detail : "Failed to load highlights"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadHighlights(activeTab); + }, [activeTab, loadHighlights]); + + const handleTabChange = (tab: FilterTab) => { + setActiveTab(tab); + setExpandedId(null); + }; + + const handleApprove = async (id: string) => { + setActionLoading(id); + try { + await updateHighlightStatus(id, "approved"); + await loadHighlights(activeTab); + } catch (err) { + setError(err instanceof ApiError ? err.detail : "Failed to update status"); + } finally { + setActionLoading(null); + } + }; + + const handleReject = async (id: string) => { + setActionLoading(id); + try { + await updateHighlightStatus(id, "rejected"); + await loadHighlights(activeTab); + } catch (err) { + setError(err instanceof ApiError ? err.detail : "Failed to update status"); + } finally { + setActionLoading(null); + } + }; + + const handleToggleTrim = async (h: HighlightCandidate) => { + if (expandedId === h.id) { + setExpandedId(null); + return; + } + // Fetch full detail to get score_breakdown + try { + const detail = await fetchHighlightDetail(h.id); + setHighlights((prev) => + prev.map((x) => (x.id === h.id ? detail : x)), + ); + } catch { + // non-critical — proceed with what we have + } + setExpandedId(h.id); + setTrimStart( + h.trim_start != null + ? String(h.trim_start) + : h.key_moment + ? String(h.key_moment.start_time) + : "0", + ); + setTrimEnd( + h.trim_end != null + ? String(h.trim_end) + : h.key_moment + ? String(h.key_moment.end_time) + : "0", + ); + }; + + const handleSaveTrim = async (id: string) => { + const start = parseFloat(trimStart); + const end = parseFloat(trimEnd); + if (isNaN(start) || isNaN(end) || start >= end || start < 0) { + setError("Invalid trim values — start must be less than end, both ≥ 0"); + return; + } + setActionLoading(id); + try { + await trimHighlight(id, start, end); + setExpandedId(null); + await loadHighlights(activeTab); + } catch (err) { + setError(err instanceof ApiError ? err.detail : "Failed to save trim"); + } finally { + setActionLoading(null); + } + }; + + const tabs: { key: FilterTab; label: string }[] = [ + { key: "all", label: "All" }, + { key: "shorts", label: "Shorts" }, + { key: "approved", label: "Approved" }, + { key: "rejected", label: "Rejected" }, + ]; + + return ( +
+ +
+

Highlight Queue

+

Review, trim, and approve auto-detected highlights

+ + {/* Filter tabs */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Error */} + {error &&
{error}
} + + {/* Loading */} + {loading &&
Loading highlights…
} + + {/* Empty */} + {!loading && !error && highlights.length === 0 && ( +
+

No highlights found

+

+ {activeTab === "all" + ? "No highlight candidates have been detected yet." + : `No ${activeTab === "shorts" ? "short (≤ 60 s)" : activeTab} highlights.`} +

+
+ )} + + {/* Candidate list */} + {!loading && highlights.length > 0 && ( +
+ {highlights.map((h) => ( +
+ {/* Header */} +
+ + {h.key_moment?.title ?? "Untitled highlight"} + + + {formatDuration(h.duration_secs)} + + {h.duration_secs <= 60 && ( + Short + )} + + {h.status} + +
+ + {/* Composite score */} +
+ + {Math.round(h.score * 100)}% + +
+
+
+
+ + {/* Score breakdown (shown when expanded) */} + {expandedId === h.id && h.score_breakdown && ( +
+ {BREAKDOWN_LABELS.map(({ key, label }) => { + const val = h.score_breakdown![key]; + return ( +
+ {label} +
+
+
+ + {Math.round(val * 100)}% + +
+ ); + })} +
+ )} + + {/* Action buttons */} +
+ + + +
+ + {/* Trim panel */} + {expandedId === h.id && ( +
+
+ Start (s) + setTrimStart(e.target.value)} + min={0} + step={0.1} + /> +
+
+ End (s) + setTrimEnd(e.target.value)} + min={0} + step={0.1} + /> +
+
+ + +
+
+ )} +
+ ))} +
+ )} +
+
+ ); +}