- "frontend/src/pages/CreatorDashboard.tsx" - "frontend/src/pages/CreatorDashboard.module.css" - "frontend/src/api/creator-dashboard.ts" GSD-Task: S07/T02
386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link, NavLink } from "react-router-dom";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
|
import {
|
|
fetchCreatorDashboard,
|
|
exportCreatorData,
|
|
type CreatorDashboardResponse,
|
|
} from "../api/creator-dashboard";
|
|
import { ApiError } from "../api/client";
|
|
import styles from "./CreatorDashboard.module.css";
|
|
|
|
function SidebarNav() {
|
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|
`${styles.sidebarLink}${isActive ? ` ${styles.sidebarLinkActive}` : ""}`;
|
|
|
|
return (
|
|
<nav className={styles.sidebar}>
|
|
<NavLink to="/creator/dashboard" className={linkClass} end>
|
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
</svg>
|
|
Dashboard
|
|
</NavLink>
|
|
<NavLink to="/creator/chapters" className={linkClass}>
|
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
</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" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
Consent
|
|
</NavLink>
|
|
<NavLink to="/creator/settings" className={linkClass}>
|
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
</svg>
|
|
Settings
|
|
</NavLink>
|
|
<NavLink to="/creator/tiers" className={linkClass}>
|
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
|
<path d="M2 17l10 5 10-5" />
|
|
<path d="M2 12l10 5 10-5" />
|
|
</svg>
|
|
Tiers
|
|
</NavLink>
|
|
<NavLink to="/creator/transparency" className={linkClass}>
|
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
Transparency
|
|
</NavLink>
|
|
<NavLink to="/creator/posts" className={linkClass}>
|
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 20h9" />
|
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
|
</svg>
|
|
Posts
|
|
</NavLink>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
export { SidebarNav };
|
|
|
|
/* ── Stat card ─────────────────────────────────────────────────────────────── */
|
|
|
|
function StatCard({ value, label }: { value: number; label: string }) {
|
|
return (
|
|
<div className={styles.statCard}>
|
|
<span className={styles.statValue}>{value.toLocaleString()}</span>
|
|
<span className={styles.statLabel}>{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Helpers ────────────────────────────────────────────────────────────────── */
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
/** Map processing_status to a badge CSS class name (module-scoped). */
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status.toLowerCase()) {
|
|
case "complete":
|
|
case "completed":
|
|
return styles.badgeComplete ?? "";
|
|
case "processing":
|
|
return styles.badgeProcessing ?? "";
|
|
case "error":
|
|
case "failed":
|
|
return styles.badgeError ?? "";
|
|
default:
|
|
return styles.badgePending ?? "";
|
|
}
|
|
}
|
|
|
|
/** Map topic_category to a badge CSS class name. */
|
|
function categoryBadgeClass(cat: string): string {
|
|
const slug = cat.toLowerCase().replace(/[\s_]+/g, "-");
|
|
const map: Record<string, string | undefined> = {
|
|
"sound-design": styles.badgeCatSoundDesign,
|
|
mixing: styles.badgeCatMixing,
|
|
synthesis: styles.badgeCatSynthesis,
|
|
arrangement: styles.badgeCatArrangement,
|
|
workflow: styles.badgeCatWorkflow,
|
|
mastering: styles.badgeCatMastering,
|
|
"music-theory": styles.badgeCatMusicTheory,
|
|
};
|
|
return map[slug] ?? styles.badgeCatDefault ?? "";
|
|
}
|
|
|
|
/* ── Main component ────────────────────────────────────────────────────────── */
|
|
|
|
export default function CreatorDashboard() {
|
|
useDocumentTitle("Creator Dashboard");
|
|
const { user } = useAuth();
|
|
|
|
const [data, setData] = useState<CreatorDashboardResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
fetchCreatorDashboard()
|
|
.then((res) => {
|
|
if (!cancelled) setData(res);
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
// No creator profile linked — show friendly empty state
|
|
setError("not_linked");
|
|
} else {
|
|
setError(err instanceof ApiError ? err.detail : "Failed to load dashboard");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
async function handleExport() {
|
|
setExporting(true);
|
|
setExportError(null);
|
|
try {
|
|
const { blob, filename } = await exportCreatorData();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
setExportError(
|
|
err instanceof ApiError ? err.detail : "Export failed — please try again."
|
|
);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={styles.layout}>
|
|
<SidebarNav />
|
|
<div className={styles.content}>
|
|
<h1 className={styles.welcome}>
|
|
Welcome back{user?.display_name ? `, ${user.display_name}` : ""}
|
|
</h1>
|
|
|
|
{loading && <DashboardSkeleton />}
|
|
|
|
{!loading && error === "not_linked" && (
|
|
<div className={styles.emptyState}>
|
|
<h2>No Creator Profile</h2>
|
|
<p>Your account isn't linked to a creator profile yet. Contact an admin to get set up.</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && error && error !== "not_linked" && (
|
|
<div className={styles.errorState}>
|
|
<p>Could not load dashboard: {error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && data && (
|
|
<>
|
|
{/* ── Stats row ──────────────────────────────────────────── */}
|
|
<div className={styles.statsRow}>
|
|
<StatCard value={data.video_count} label="Uploads" />
|
|
<StatCard value={data.technique_count} label="Techniques" />
|
|
<StatCard value={data.key_moment_count} label="Key Moments" />
|
|
<StatCard value={data.search_impressions} label="Search Impressions" />
|
|
</div>
|
|
|
|
{/* ── Export ─────────────────────────────────────────────── */}
|
|
<div className={styles.exportRow}>
|
|
<button
|
|
className={styles.exportBtn}
|
|
onClick={handleExport}
|
|
disabled={exporting}
|
|
>
|
|
{exporting ? (
|
|
<>
|
|
<span className={styles.exportSpinner} aria-hidden="true" />
|
|
Exporting…
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className={styles.exportIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="7 10 12 15 17 10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
Export My Data
|
|
</>
|
|
)}
|
|
</button>
|
|
{exportError && (
|
|
<span className={styles.exportError}>{exportError}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Techniques ─────────────────────────────────────────── */}
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>Technique Pages</h2>
|
|
{data.techniques.length === 0 ? (
|
|
<p className={styles.emptyText}>No technique pages yet.</p>
|
|
) : (
|
|
<>
|
|
{/* Desktop table */}
|
|
<div className={styles.tableWrap}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>Category</th>
|
|
<th>Moments</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.techniques.map((t) => (
|
|
<tr key={t.slug}>
|
|
<td>
|
|
<Link to={`/techniques/${t.slug}`} className={styles.link}>
|
|
{t.title}
|
|
</Link>
|
|
</td>
|
|
<td>
|
|
<span className={`${styles.badge} ${categoryBadgeClass(t.topic_category)}`}>
|
|
{t.topic_category}
|
|
</span>
|
|
</td>
|
|
<td>{t.key_moment_count}</td>
|
|
<td>{formatDate(t.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile cards */}
|
|
<div className={styles.mobileCards}>
|
|
{data.techniques.map((t) => (
|
|
<div key={t.slug} className={styles.mobileCard}>
|
|
<Link to={`/techniques/${t.slug}`} className={styles.link}>
|
|
{t.title}
|
|
</Link>
|
|
<div className={styles.mobileCardMeta}>
|
|
<span className={`${styles.badge} ${categoryBadgeClass(t.topic_category)}`}>
|
|
{t.topic_category}
|
|
</span>
|
|
<span>{t.key_moment_count} moments</span>
|
|
<span>{formatDate(t.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Videos ──────────────────────────────────────────────── */}
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>Source Videos</h2>
|
|
{data.videos.length === 0 ? (
|
|
<p className={styles.emptyText}>No videos uploaded yet.</p>
|
|
) : (
|
|
<>
|
|
<div className={styles.tableWrap}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th>Filename</th>
|
|
<th>Status</th>
|
|
<th>Uploaded</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.videos.map((v) => (
|
|
<tr key={v.filename}>
|
|
<td className={styles.filename}>{v.filename}</td>
|
|
<td>
|
|
<span className={`${styles.badge} ${statusBadgeClass(v.processing_status)}`}>
|
|
{v.processing_status}
|
|
</span>
|
|
</td>
|
|
<td>{formatDate(v.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className={styles.mobileCards}>
|
|
{data.videos.map((v) => (
|
|
<div key={v.filename} className={styles.mobileCard}>
|
|
<span className={styles.filename}>{v.filename}</span>
|
|
<div className={styles.mobileCardMeta}>
|
|
<span className={`${styles.badge} ${statusBadgeClass(v.processing_status)}`}>
|
|
{v.processing_status}
|
|
</span>
|
|
<span>{formatDate(v.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Loading skeleton ──────────────────────────────────────────────────────── */
|
|
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<div className={styles.skeleton}>
|
|
<div className={styles.statsRow}>
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className={`${styles.statCard} ${styles.skeletonPulse}`} />
|
|
))}
|
|
</div>
|
|
<div className={`${styles.skeletonBlock} ${styles.skeletonPulse}`} />
|
|
<div className={`${styles.skeletonBlock} ${styles.skeletonPulse}`} />
|
|
</div>
|
|
);
|
|
}
|