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
|
- Estimate: 45m
|
||||||
- Files: backend/routers/creator_dashboard.py, backend/models.py, backend/tests/test_export.py
|
- 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
|
- 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.
|
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 ────────────────────────────────────────────────────────────────────
|
// ── 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