feat: Replaced 3 placeholder cards with real creator dashboard: 4 stat…

- "frontend/src/api/creator-dashboard.ts"
- "frontend/src/pages/CreatorDashboard.tsx"
- "frontend/src/pages/CreatorDashboard.module.css"
- "frontend/src/api/index.ts"

GSD-Task: S02/T02
This commit is contained in:
jlightner 2026-04-04 00:13:48 +00:00
parent e665e82c25
commit 2e7fa224bc
5 changed files with 531 additions and 30 deletions

View file

@ -0,0 +1,32 @@
import { request, BASE } from "./client";
// ── Types ────────────────────────────────────────────────────────────────────
export interface CreatorDashboardTechnique {
title: string;
slug: string;
topic_category: string;
created_at: string;
key_moment_count: number;
}
export interface CreatorDashboardVideo {
filename: string;
processing_status: string;
created_at: string;
}
export interface CreatorDashboardResponse {
video_count: number;
technique_count: number;
key_moment_count: number;
search_impressions: number;
techniques: CreatorDashboardTechnique[];
videos: CreatorDashboardVideo[];
}
// ── Functions ────────────────────────────────────────────────────────────────
export async function fetchCreatorDashboard(): Promise<CreatorDashboardResponse> {
return request<CreatorDashboardResponse>(`${BASE}/creator/dashboard`);
}

View file

@ -13,3 +13,4 @@ export * from "./reports";
export * from "./admin-pipeline";
export * from "./admin-techniques";
export * from "./auth";
export * from "./creator-dashboard";

View file

@ -76,33 +76,263 @@
color: var(--color-text-primary);
}
.cards {
/* ── Stats row ─────────────────────────────────────────────────────────────── */
.statsRow {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.card {
.statCard {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 80px;
}
.cardTitle {
font-size: 0.875rem;
.statValue {
font-size: 1.75rem;
font-weight: 700;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.statLabel {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ── Sections ──────────────────────────────────────────────────────────────── */
.section {
margin-bottom: 2rem;
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 0.75rem;
}
.emptyText {
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* ── Table ─────────────────────────────────────────────────────────────────── */
.tableWrap {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th {
text-align: left;
padding: 0.625rem 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--color-border);
}
.table td {
padding: 0.625rem 0.75rem;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-subtle, var(--color-border));
}
.table tbody tr:hover {
background: var(--color-bg-surface-hover);
}
.link {
color: var(--color-accent);
text-decoration: none;
font-weight: 500;
}
.link:hover {
text-decoration: underline;
}
.filename {
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
word-break: break-all;
}
/* ── Badges ────────────────────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
/* Processing status badges */
.badgeComplete {
background: var(--color-badge-approved-bg);
color: var(--color-badge-approved-text);
}
.badgeProcessing {
background: var(--color-badge-edited-bg);
color: var(--color-badge-edited-text);
}
.badgeError {
background: var(--color-badge-rejected-bg);
color: var(--color-badge-rejected-text);
}
.badgePending {
background: var(--color-badge-pending-bg);
color: var(--color-badge-pending-text);
}
/* Category badges */
.badgeCatSoundDesign {
background: var(--color-badge-cat-sound-design-bg);
color: var(--color-badge-cat-sound-design-text);
}
.badgeCatMixing {
background: var(--color-badge-cat-mixing-bg);
color: var(--color-badge-cat-mixing-text);
}
.badgeCatSynthesis {
background: var(--color-badge-cat-synthesis-bg);
color: var(--color-badge-cat-synthesis-text);
}
.badgeCatArrangement {
background: var(--color-badge-cat-arrangement-bg);
color: var(--color-badge-cat-arrangement-text);
}
.badgeCatWorkflow {
background: var(--color-badge-cat-workflow-bg);
color: var(--color-badge-cat-workflow-text);
}
.badgeCatMastering {
background: var(--color-badge-cat-mastering-bg);
color: var(--color-badge-cat-mastering-text);
}
.badgeCatMusicTheory {
background: var(--color-badge-cat-music-theory-bg);
color: var(--color-badge-cat-music-theory-text);
}
.badgeCatDefault {
background: var(--color-badge-category-bg);
color: var(--color-badge-category-text);
}
/* ── Mobile cards (hidden on desktop, shown on mobile) ─────────────────── */
.mobileCards {
display: none;
}
.mobileCard {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.875rem 1rem;
}
.mobileCardMeta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.375rem;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
/* ── Empty / error states ──────────────────────────────────────────────────── */
.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;
}
.cardBody {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.5;
.emptyState p {
font-size: 0.875rem;
margin: 0;
}
/* ── Mobile ────────────────────────────────────────────────────────────────── */
.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;
}
/* ── Skeleton loading ──────────────────────────────────────────────────────── */
.skeleton {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.skeletonPulse {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
animation: pulse 1.5s ease-in-out infinite;
}
.skeletonBlock {
height: 120px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Responsive ────────────────────────────────────────────────────────────── */
@media (max-width: 1024px) {
.statsRow {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.layout {
@ -129,4 +359,19 @@
.content {
padding: 1.25rem;
}
.statsRow {
grid-template-columns: 1fr;
}
/* Hide table, show mobile cards */
.tableWrap {
display: none;
}
.mobileCards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}

View file

@ -1,6 +1,12 @@
import { NavLink } from "react-router-dom";
import { useEffect, useState } from "react";
import { Link, NavLink } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import {
fetchCreatorDashboard,
type CreatorDashboardResponse,
} from "../api/creator-dashboard";
import { ApiError } from "../api/client";
import styles from "./CreatorDashboard.module.css";
function SidebarNav() {
@ -42,10 +48,95 @@ function SidebarNav() {
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);
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;
};
}, []);
return (
<div className={styles.layout}>
<SidebarNav />
@ -53,27 +144,159 @@ export default function CreatorDashboard() {
<h1 className={styles.welcome}>
Welcome back{user?.display_name ? `, ${user.display_name}` : ""}
</h1>
<div className={styles.cards}>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Content Stats</h2>
<p className={styles.cardBody}>
Content analytics coming in M020. You'll see views, engagement, and technique performance here.
</p>
{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>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Recent Activity</h2>
<p className={styles.cardBody}>
Activity feed coming soon. Track updates to your technique pages.
</p>
)}
{!loading && error && error !== "not_linked" && (
<div className={styles.errorState}>
<p>Could not load dashboard: {error}</p>
</div>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Quick Actions</h2>
<p className={styles.cardBody}>
Content management tools will appear here once the creator content module is live.
</p>
)}
{!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>
{/* ── 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>
);
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}