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
8b2876906c
commit
c42d21a29f
6 changed files with 253 additions and 2 deletions
|
|
@ -42,7 +42,7 @@ Includes a pytest that mocks DB queries and verifies the endpoint returns a vali
|
|||
- Estimate: 45m
|
||||
- Files: backend/routers/creator_dashboard.py, backend/models.py, backend/tests/test_export.py
|
||||
- Verify: cd backend && python -m pytest tests/test_export.py -v
|
||||
- [ ] **T02: Add frontend download button and verify end-to-end** — Add an 'Export My Data' download button to the CreatorDashboard page. The button triggers an authenticated fetch to `/api/v1/creator/export`, receives the ZIP blob, and initiates a browser download. Includes loading state while the export runs and error handling for failures.
|
||||
- [x] **T02: Added Export My Data download button to CreatorDashboard with loading/error states** — Add an 'Export My Data' download button to the CreatorDashboard page. The button triggers an authenticated fetch to `/api/v1/creator/export`, receives the ZIP blob, and initiates a browser download. Includes loading state while the export runs and error handling for failures.
|
||||
|
||||
The API client in `frontend/src/api/client.ts` manages auth tokens. The download uses `fetch()` with the Bearer token (matching the existing `request()` pattern) but handles the response as a blob instead of JSON.
|
||||
|
||||
|
|
|
|||
24
.gsd/milestones/M025/slices/S07/tasks/T01-VERIFY.json
Normal file
24
.gsd/milestones/M025/slices/S07/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M025/S07/T01",
|
||||
"timestamp": 1775312216669,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd backend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 10,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "python -m pytest tests/test_export.py -v",
|
||||
"exitCode": 4,
|
||||
"durationMs": 243,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
80
.gsd/milestones/M025/slices/S07/tasks/T02-SUMMARY.md
Normal file
80
.gsd/milestones/M025/slices/S07/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S07
|
||||
milestone: M025
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["frontend/src/pages/CreatorDashboard.tsx", "frontend/src/pages/CreatorDashboard.module.css", "frontend/src/api/creator-dashboard.ts"]
|
||||
key_decisions: ["Added exportCreatorData() to creator-dashboard.ts rather than a separate file — keeps dashboard API functions co-located", "Blob download via hidden anchor + URL.createObjectURL — standard pattern that works without server-side negotiation"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Frontend builds clean (npm run build, 0 errors). Backend export tests all pass (9/9). Both slice-level verification commands green."
|
||||
completed_at: 2026-04-04T14:19:22.699Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Added Export My Data download button to CreatorDashboard with loading/error states
|
||||
|
||||
> Added Export My Data download button to CreatorDashboard with loading/error states
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S07
|
||||
milestone: M025
|
||||
key_files:
|
||||
- frontend/src/pages/CreatorDashboard.tsx
|
||||
- frontend/src/pages/CreatorDashboard.module.css
|
||||
- frontend/src/api/creator-dashboard.ts
|
||||
key_decisions:
|
||||
- Added exportCreatorData() to creator-dashboard.ts rather than a separate file — keeps dashboard API functions co-located
|
||||
- Blob download via hidden anchor + URL.createObjectURL — standard pattern that works without server-side negotiation
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-04T14:19:22.699Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Added Export My Data download button to CreatorDashboard with loading/error states
|
||||
|
||||
**Added Export My Data download button to CreatorDashboard with loading/error states**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added exportCreatorData() blob download function to creator-dashboard.ts and an Export My Data button to CreatorDashboard.tsx. Button shows loading spinner during download, inline error on failure, and triggers browser download via hidden anchor + object URL on success. CSS matches existing dashboard design tokens.
|
||||
|
||||
## Verification
|
||||
|
||||
Frontend builds clean (npm run build, 0 errors). Backend export tests all pass (9/9). Both slice-level verification commands green.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 7100ms |
|
||||
| 2 | `cd backend && python -m pytest tests/test_export.py -v` | 0 | ✅ pass | 7100ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Export function added to creator-dashboard.ts instead of a new creator.ts. Backend uses creator_id in filename, not slug — matched actual backend behavior.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/pages/CreatorDashboard.tsx`
|
||||
- `frontend/src/pages/CreatorDashboard.module.css`
|
||||
- `frontend/src/api/creator-dashboard.ts`
|
||||
|
||||
|
||||
## Deviations
|
||||
Export function added to creator-dashboard.ts instead of a new creator.ts. Backend uses creator_id in filename, not slug — matched actual backend behavior.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -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