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 ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -30,3 +30,37 @@ export interface CreatorDashboardResponse {
|
||||||
export async function fetchCreatorDashboard(): Promise<CreatorDashboardResponse> {
|
export async function fetchCreatorDashboard(): Promise<CreatorDashboardResponse> {
|
||||||
return request<CreatorDashboardResponse>(`${BASE}/creator/dashboard`);
|
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;
|
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 ──────────────────────────────────────────────────────────────── */
|
/* ── Sections ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useAuth } from "../context/AuthContext";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
import {
|
import {
|
||||||
fetchCreatorDashboard,
|
fetchCreatorDashboard,
|
||||||
|
exportCreatorData,
|
||||||
type CreatorDashboardResponse,
|
type CreatorDashboardResponse,
|
||||||
} from "../api/creator-dashboard";
|
} from "../api/creator-dashboard";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
|
@ -140,6 +141,8 @@ export default function CreatorDashboard() {
|
||||||
const [data, setData] = useState<CreatorDashboardResponse | null>(null);
|
const [data, setData] = useState<CreatorDashboardResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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 (
|
return (
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
|
|
@ -201,6 +226,34 @@ export default function CreatorDashboard() {
|
||||||
<StatCard value={data.search_impressions} label="Search Impressions" />
|
<StatCard value={data.search_impressions} label="Search Impressions" />
|
||||||
</div>
|
</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 ─────────────────────────────────────────── */}
|
{/* ── Techniques ─────────────────────────────────────────── */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Technique Pages</h2>
|
<h2 className={styles.sectionTitle}>Technique Pages</h2>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue