From 8e3ba00aab2bf12c730cec97040f9adaf5e086d2 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 07:01:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20HighlightQueue=20page=20with=20?= =?UTF-8?q?filter=20tabs,=20candidate=20cards=20with=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- frontend/src/App.tsx | 2 + frontend/src/api/highlights.ts | 81 ++++ frontend/src/pages/CreatorDashboard.tsx | 6 + frontend/src/pages/HighlightQueue.module.css | 388 +++++++++++++++++++ frontend/src/pages/HighlightQueue.tsx | 337 ++++++++++++++++ 5 files changed, 814 insertions(+) create mode 100644 frontend/src/api/highlights.ts create mode 100644 frontend/src/pages/HighlightQueue.module.css create mode 100644 frontend/src/pages/HighlightQueue.tsx 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} + /> +
+
+ + +
+
+ )} +
+ ))} +
+ )} +
+
+ ); +}