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:
parent
87f09f0192
commit
8e3ba00aab
5 changed files with 814 additions and 0 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
81
frontend/src/api/highlights.ts
Normal file
81
frontend/src/api/highlights.ts
Normal 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 }) },
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
388
frontend/src/pages/HighlightQueue.module.css
Normal file
388
frontend/src/pages/HighlightQueue.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
337
frontend/src/pages/HighlightQueue.tsx
Normal file
337
frontend/src/pages/HighlightQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue