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:
parent
0b8dcf2ccf
commit
f2edb1f375
3 changed files with 148 additions and 1 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue