feat: Built HighlightQueue page with filter tabs, candidate cards with…

- "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"

GSD-Task: S01/T02
This commit is contained in:
jlightner 2026-04-04 07:01:57 +00:00
parent c05e4da594
commit ce08d729cd
8 changed files with 930 additions and 1 deletions

View file

@ -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

View file

@ -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"
}
]
}

View file

@ -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.

View file

@ -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() {
<Route path="/creator/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} />
<Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -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<HighlightListResponse> {
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<HighlightListResponse>(
`${BASE}/creator/highlights${qs ? `?${qs}` : ""}`,
);
}
export function fetchHighlightDetail(
id: string,
): Promise<HighlightCandidate> {
return request<HighlightCandidate>(
`${BASE}/creator/highlights/${encodeURIComponent(id)}`,
);
}
export function updateHighlightStatus(
id: string,
status: string,
): Promise<HighlightCandidate> {
return request<HighlightCandidate>(
`${BASE}/creator/highlights/${encodeURIComponent(id)}`,
{ method: "PATCH", body: JSON.stringify({ status }) },
);
}
export function trimHighlight(
id: string,
trim_start: number,
trim_end: number,
): Promise<HighlightCandidate> {
return request<HighlightCandidate>(
`${BASE}/creator/highlights/${encodeURIComponent(id)}/trim`,
{ method: "PATCH", body: JSON.stringify({ trim_start, trim_end }) },
);
}

View file

@ -31,6 +31,12 @@ function SidebarNav() {
</svg>
Chapters
</NavLink>
<NavLink to="/creator/highlights" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
Highlights
</NavLink>
<NavLink to="/creator/consent" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />

View file

@ -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;
}
}

View file

@ -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<HighlightCandidate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<FilterTab>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [trimStart, setTrimStart] = useState<string>("");
const [trimEnd, setTrimEnd] = useState<string>("");
const [actionLoading, setActionLoading] = useState<string | null>(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 (
<div className={styles.layout}>
<SidebarNav />
<div className={styles.content}>
<h1 className={styles.pageTitle}>Highlight Queue</h1>
<p className={styles.subtitle}>Review, trim, and approve auto-detected highlights</p>
{/* Filter tabs */}
<div className={styles.filterTabs}>
{tabs.map((t) => (
<button
key={t.key}
className={`${styles.filterTab}${activeTab === t.key ? ` ${styles.filterTabActive}` : ""}`}
onClick={() => handleTabChange(t.key)}
>
{t.label}
</button>
))}
</div>
{/* Error */}
{error && <div className={styles.errorState}>{error}</div>}
{/* Loading */}
{loading && <div className={styles.loadingState}>Loading highlights</div>}
{/* Empty */}
{!loading && !error && highlights.length === 0 && (
<div className={styles.emptyState}>
<h2>No highlights found</h2>
<p>
{activeTab === "all"
? "No highlight candidates have been detected yet."
: `No ${activeTab === "shorts" ? "short (≤ 60 s)" : activeTab} highlights.`}
</p>
</div>
)}
{/* Candidate list */}
{!loading && highlights.length > 0 && (
<div className={styles.candidateList}>
{highlights.map((h) => (
<div key={h.id} className={styles.candidateCard}>
{/* Header */}
<div className={styles.candidateHeader}>
<span className={styles.candidateTitle}>
{h.key_moment?.title ?? "Untitled highlight"}
</span>
<span className={styles.candidateDuration}>
{formatDuration(h.duration_secs)}
</span>
{h.duration_secs <= 60 && (
<span className={styles.shortsBadge}>Short</span>
)}
<span className={`${styles.badge} ${statusBadgeClass(h.status)}`}>
{h.status}
</span>
</div>
{/* Composite score */}
<div className={styles.compositeScore}>
<span className={styles.compositeLabel}>
{Math.round(h.score * 100)}%
</span>
<div className={styles.scoreBarTrack}>
<div
className={styles.scoreBarFill}
style={{ width: `${Math.round(h.score * 100)}%` }}
/>
</div>
</div>
{/* Score breakdown (shown when expanded) */}
{expandedId === h.id && h.score_breakdown && (
<div className={styles.breakdownSection}>
{BREAKDOWN_LABELS.map(({ key, label }) => {
const val = h.score_breakdown![key];
return (
<div key={key} className={styles.breakdownRow}>
<span className={styles.breakdownLabel}>{label}</span>
<div className={styles.breakdownBar}>
<div
className={styles.breakdownBarFill}
style={{ width: `${Math.round(val * 100)}%` }}
/>
</div>
<span className={styles.breakdownValue}>
{Math.round(val * 100)}%
</span>
</div>
);
})}
</div>
)}
{/* Action buttons */}
<div className={styles.actions}>
<button
className={`${styles.actionBtn} ${styles.approveBtn}`}
disabled={actionLoading === h.id || h.status === "approved"}
onClick={() => handleApprove(h.id)}
>
Approve
</button>
<button
className={`${styles.actionBtn} ${styles.rejectBtn}`}
disabled={actionLoading === h.id || h.status === "rejected"}
onClick={() => handleReject(h.id)}
>
Discard
</button>
<button
className={styles.actionBtn}
disabled={actionLoading === h.id}
onClick={() => handleToggleTrim(h)}
>
{expandedId === h.id ? "Close" : "Trim"}
</button>
</div>
{/* Trim panel */}
{expandedId === h.id && (
<div className={styles.trimPanel}>
<div className={styles.trimField}>
<span className={styles.trimLabel}>Start (s)</span>
<input
type="number"
className={styles.trimInput}
value={trimStart}
onChange={(e) => setTrimStart(e.target.value)}
min={0}
step={0.1}
/>
</div>
<div className={styles.trimField}>
<span className={styles.trimLabel}>End (s)</span>
<input
type="number"
className={styles.trimInput}
value={trimEnd}
onChange={(e) => setTrimEnd(e.target.value)}
min={0}
step={0.1}
/>
</div>
<div className={styles.trimActions}>
<button
className={`${styles.actionBtn} ${styles.approveBtn}`}
disabled={actionLoading === h.id}
onClick={() => handleSaveTrim(h.id)}
>
Save
</button>
<button
className={styles.actionBtn}
onClick={() => setExpandedId(null)}
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}