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
This commit is contained in:
jlightner 2026-04-04 14:19:30 +00:00
parent 0b8dcf2ccf
commit f2edb1f375
3 changed files with 148 additions and 1 deletions

View file

@ -1,4 +1,4 @@
import { request, BASE } from "./client";
import { request, BASE, ApiError, AUTH_TOKEN_KEY } from "./client";
// ── Types ────────────────────────────────────────────────────────────────────
@ -30,3 +30,37 @@ export interface CreatorDashboardResponse {
export async function fetchCreatorDashboard(): Promise<CreatorDashboardResponse> {
return request<CreatorDashboardResponse>(`${BASE}/creator/dashboard`);
}
/**
* Download the creator's full data export as a ZIP blob.
* Uses fetch() directly because the response is binary, not JSON.
*/
export async function exportCreatorData(): Promise<{ blob: Blob; filename: string }> {
const token = localStorage.getItem(AUTH_TOKEN_KEY);
const res = await fetch(`${BASE}/creator/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
let detail = res.statusText;
try {
const body: unknown = await res.json();
if (typeof body === "object" && body !== null && "detail" in body) {
const d = (body as { detail: unknown }).detail;
detail = typeof d === "string" ? d : JSON.stringify(d);
}
} catch {
// body not JSON — keep statusText
}
throw new ApiError(res.status, detail);
}
const blob = await res.blob();
// Extract filename from Content-Disposition header, fallback to default
const cd = res.headers.get("Content-Disposition") ?? "";
const match = cd.match(/filename="?([^";\s]+)"?/);
const filename = match?.[1] ?? "chrysopedia-export.zip";
return { blob, filename };
}

View file

@ -112,6 +112,66 @@
letter-spacing: 0.04em;
}
/* ── Export button ──────────────────────────────────────────────────────────── */
.exportRow {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.exportBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.exportBtn:hover:not(:disabled) {
background: var(--color-bg-surface-hover);
border-color: var(--color-accent);
}
.exportBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.exportIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.exportSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.exportError {
font-size: 0.8125rem;
color: var(--color-error, #ef4444);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Sections ──────────────────────────────────────────────────────────────── */
.section {

View file

@ -4,6 +4,7 @@ import { useAuth } from "../context/AuthContext";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import {
fetchCreatorDashboard,
exportCreatorData,
type CreatorDashboardResponse,
} from "../api/creator-dashboard";
import { ApiError } from "../api/client";
@ -140,6 +141,8 @@ export default function CreatorDashboard() {
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;
@ -168,6 +171,28 @@ export default function CreatorDashboard() {
};
}, []);
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 />
@ -201,6 +226,34 @@ export default function CreatorDashboard() {
<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>