chrysopedia/frontend/src/pages/CreatorDashboard.tsx
jlightner c42d21a29f feat: Added Export My Data download button to CreatorDashboard with loa…
- "frontend/src/pages/CreatorDashboard.tsx"
- "frontend/src/pages/CreatorDashboard.module.css"
- "frontend/src/api/creator-dashboard.ts"

GSD-Task: S07/T02
2026-04-04 14:19:30 +00:00

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