diff --git a/frontend/src/api/creator-dashboard.ts b/frontend/src/api/creator-dashboard.ts index e8ea572..3495724 100644 --- a/frontend/src/api/creator-dashboard.ts +++ b/frontend/src/api/creator-dashboard.ts @@ -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 { return request(`${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 }; +} diff --git a/frontend/src/pages/CreatorDashboard.module.css b/frontend/src/pages/CreatorDashboard.module.css index 50e1bfc..fef47a8 100644 --- a/frontend/src/pages/CreatorDashboard.module.css +++ b/frontend/src/pages/CreatorDashboard.module.css @@ -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 { diff --git a/frontend/src/pages/CreatorDashboard.tsx b/frontend/src/pages/CreatorDashboard.tsx index 54586b6..43db374 100644 --- a/frontend/src/pages/CreatorDashboard.tsx +++ b/frontend/src/pages/CreatorDashboard.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(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 (
@@ -201,6 +226,34 @@ export default function CreatorDashboard() {
+ {/* ── Export ─────────────────────────────────────────────── */} +
+ + {exportError && ( + {exportError} + )} +
+ {/* ── Techniques ─────────────────────────────────────────── */}

Technique Pages