feat: Created TypeScript consent API client with 5 fetch functions and…
- "frontend/src/api/consent.ts" - "frontend/src/components/ToggleSwitch.tsx" - "frontend/src/components/ToggleSwitch.module.css" - "frontend/src/api/index.ts" GSD-Task: S03/T01
This commit is contained in:
parent
da29a2a723
commit
31638b5a3a
14 changed files with 704 additions and 2 deletions
|
|
@ -42,3 +42,4 @@
|
||||||
| D034 | | architecture | Documentation strategy for Phase 2 | Forgejo wiki at forgejo.xpltd.co populated incrementally — KB slice at end of every milestone | KB stays current by documenting what just shipped at each milestone boundary. Final comprehensive pass in M025. Newcomers can onboard at any point during Phase 2 development. | Yes | collaborative |
|
| D034 | | architecture | Documentation strategy for Phase 2 | Forgejo wiki at forgejo.xpltd.co populated incrementally — KB slice at end of every milestone | KB stays current by documenting what just shipped at each milestone boundary. Final comprehensive pass in M025. Newcomers can onboard at any point during Phase 2 development. | Yes | collaborative |
|
||||||
| D035 | | architecture | File/object storage for creator posts, shorts, and file distribution | MinIO (S3-compatible) self-hosted on ub01 home server stack | Docker-native, S3-compatible API for signed URLs with expiration. Already fits the self-hosted infrastructure model. Handles presets, sample packs, shorts output, and gated downloads. | Yes | collaborative |
|
| D035 | | architecture | File/object storage for creator posts, shorts, and file distribution | MinIO (S3-compatible) self-hosted on ub01 home server stack | Docker-native, S3-compatible API for signed URLs with expiration. Already fits the self-hosted infrastructure model. Handles presets, sample packs, shorts output, and gated downloads. | Yes | collaborative |
|
||||||
| D036 | M019/S02 | architecture | JWT auth configuration for creator authentication | HS256 with existing app_secret_key, 24-hour expiry, OAuth2PasswordBearer at /api/v1/auth/login | Reuses existing secret from config.py settings. 24-hour expiry balances convenience with security for a single-admin/invite-only tool. OAuth2PasswordBearer integrates with FastAPI's dependency injection and auto-generates OpenAPI security schemes. | Yes | agent |
|
| D036 | M019/S02 | architecture | JWT auth configuration for creator authentication | HS256 with existing app_secret_key, 24-hour expiry, OAuth2PasswordBearer at /api/v1/auth/login | Reuses existing secret from config.py settings. 24-hour expiry balances convenience with security for a single-admin/invite-only tool. OAuth2PasswordBearer integrates with FastAPI's dependency injection and auto-generates OpenAPI security schemes. | Yes | agent |
|
||||||
|
| D037 | | architecture | Search impressions query strategy for creator dashboard | Exact case-insensitive title match via EXISTS subquery against SearchLog | MVP approach — counts SearchLog rows where query exactly matches (case-insensitive) any of the creator's technique page titles. Sufficient for initial dashboard. Can be expanded to ILIKE partial matching or full-text search later when more search data accumulates. | Yes | agent |
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Creators can log in, see analytics, play video in a custom player, and manage co
|
||||||
| ID | Slice | Risk | Depends | Done | After this |
|
| ID | Slice | Risk | Depends | Done | After this |
|
||||||
|----|-------|------|---------|------|------------|
|
|----|-------|------|---------|------|------------|
|
||||||
| S01 | [A] Web Media Player MVP | high | — | ✅ | Custom video player with HLS playback, speed controls (0.5x-2x), and synchronized transcript sidebar |
|
| S01 | [A] Web Media Player MVP | high | — | ✅ | Custom video player with HLS playback, speed controls (0.5x-2x), and synchronized transcript sidebar |
|
||||||
| S02 | [A] Creator Dashboard with Real Analytics | medium | — | ⬜ | Dashboard shows upload count, technique pages generated, search impressions, content library |
|
| S02 | [A] Creator Dashboard with Real Analytics | medium | — | ✅ | Dashboard shows upload count, technique pages generated, search impressions, content library |
|
||||||
| S03 | [A] Consent Dashboard UI | low | — | ⬜ | Creator can toggle per-video consent settings (KB, AI training, shorts, embedding) through the dashboard |
|
| S03 | [A] Consent Dashboard UI | low | — | ⬜ | Creator can toggle per-video consent settings (KB, AI training, shorts, embedding) through the dashboard |
|
||||||
| S04 | [A] Admin Impersonation | high | — | ⬜ | Admin clicks View As next to any creator → sees the site as that creator with amber warning banner. Read-only. Full audit log. |
|
| S04 | [A] Admin Impersonation | high | — | ⬜ | Admin clicks View As next to any creator → sees the site as that creator with amber warning banner. Read-only. Full audit log. |
|
||||||
| S05 | [B] LightRAG Validation & A/B Testing | medium | — | ⬜ | Side-by-side comparison of top 20 queries: current Qdrant search vs LightRAG results with quality scoring |
|
| S05 | [B] LightRAG Validation & A/B Testing | medium | — | ⬜ | Side-by-side comparison of top 20 queries: current Qdrant search vs LightRAG results with quality scoring |
|
||||||
|
|
|
||||||
94
.gsd/milestones/M020/slices/S02/S02-SUMMARY.md
Normal file
94
.gsd/milestones/M020/slices/S02/S02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
id: S02
|
||||||
|
parent: M020
|
||||||
|
milestone: M020
|
||||||
|
provides:
|
||||||
|
- GET /api/v1/creator/dashboard endpoint with auth
|
||||||
|
- CreatorDashboard UI with real analytics
|
||||||
|
requires:
|
||||||
|
[]
|
||||||
|
affects:
|
||||||
|
- S07
|
||||||
|
key_files:
|
||||||
|
- backend/routers/creator_dashboard.py
|
||||||
|
- backend/schemas.py
|
||||||
|
- backend/main.py
|
||||||
|
- frontend/src/api/creator-dashboard.ts
|
||||||
|
- frontend/src/pages/CreatorDashboard.tsx
|
||||||
|
- frontend/src/pages/CreatorDashboard.module.css
|
||||||
|
key_decisions:
|
||||||
|
- Search impressions use exact case-insensitive title match via EXISTS subquery (D037)
|
||||||
|
- Alembic migration 016 rewritten to raw SQL to avoid asyncpg enum double-creation bug
|
||||||
|
- Separate desktop table and mobile card views toggled via CSS media queries
|
||||||
|
patterns_established:
|
||||||
|
- Authenticated creator endpoint pattern: resolve creator_id from user → 404 if null → aggregate queries → content lists
|
||||||
|
- CSS module ?? '' fallback pattern for noUncheckedIndexedAccess strict mode
|
||||||
|
observability_surfaces:
|
||||||
|
- chrysopedia.creator_dashboard logger with per-request stats (video_count, technique_count, moments, impressions)
|
||||||
|
drill_down_paths:
|
||||||
|
- .gsd/milestones/M020/slices/S02/tasks/T01-SUMMARY.md
|
||||||
|
- .gsd/milestones/M020/slices/S02/tasks/T02-SUMMARY.md
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-04T00:15:19.382Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# S02: [A] Creator Dashboard with Real Analytics
|
||||||
|
|
||||||
|
**Added authenticated creator dashboard endpoint and replaced placeholder UI with real analytics — 4 stat cards, techniques table, videos table, responsive layout.**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Two tasks delivered the full creator dashboard vertical slice.
|
||||||
|
|
||||||
|
**T01 (Backend):** Created `GET /api/v1/creator/dashboard` behind `get_current_user` auth. The endpoint resolves `creator_id` from the authenticated user, runs aggregate count queries (videos, techniques, key moments), computes search impressions via an EXISTS subquery against SearchLog, and returns content lists (techniques with per-page key moment counts, videos with processing status). Added Pydantic response schemas to `schemas.py` and registered the router in `main.py`. Fixed Alembic migration 016 to use raw SQL for enum/table creation (asyncpg double-creation bug workaround). Verified on ub01 via direct API and nginx proxy — authenticated requests return correct JSON, unauthenticated returns 401.
|
||||||
|
|
||||||
|
**T02 (Frontend):** Created `frontend/src/api/creator-dashboard.ts` with TypeScript types matching the backend response and a `fetchCreatorDashboard()` function using the shared `request<T>()` helper. Rewrote `CreatorDashboard.tsx` from 3 placeholder cards to a full dashboard: 4 stat cards (uploads, techniques, key moments, search impressions), techniques table with linked titles and category badges, videos table with processing status badges. Handles loading skeleton, error state, and not-linked empty state. Responsive layout — tables on desktop, stacked cards on mobile. TypeScript strict mode satisfied with `?? ''` fallback on CSS module class lookups.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — zero errors (frontend type check)
|
||||||
|
- `npm run build` — Vite production build succeeds, 88 modules transformed
|
||||||
|
- Backend endpoint verified on ub01: authenticated returns full JSON payload (video_count=3, technique_count=2, key_moment_count=30, search_impressions=0, techniques array, videos array)
|
||||||
|
- Unauthenticated request returns 401
|
||||||
|
- Works through both direct API (:8000) and nginx proxy (:8096)
|
||||||
|
|
||||||
|
## Requirements Advanced
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Requirements Validated
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## New Requirements Surfaced
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Requirements Invalidated or Re-scoped
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
Migration 016 rewritten to raw SQL to avoid asyncpg enum double-creation bug. Entire backend synced to ub01 (was behind). CSS module class lookups needed ?? '' fallback for noUncheckedIndexedAccess strict mode.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
search_impressions returns 0 on current data — exact title match finds no hits in SearchLog. Will become useful as search traffic grows. Can be expanded to ILIKE partial matching later.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
Consider ILIKE partial matching for search impressions when there's enough search data to evaluate. Add GET /api/v1/creator/dashboard integration test with a seeded creator.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `backend/routers/creator_dashboard.py` — New authenticated creator dashboard endpoint with aggregate queries and content lists
|
||||||
|
- `backend/schemas.py` — Added CreatorDashboardResponse, CreatorDashboardTechnique, CreatorDashboardVideo Pydantic schemas
|
||||||
|
- `backend/main.py` — Registered creator_dashboard router at /api/v1/creator
|
||||||
|
- `alembic/versions/016_add_users_and_invite_codes.py` — Rewritten to raw SQL for enum/table creation (asyncpg bug workaround)
|
||||||
|
- `frontend/src/api/creator-dashboard.ts` — New API module with TS types and fetchCreatorDashboard() function
|
||||||
|
- `frontend/src/api/index.ts` — Re-exported creator-dashboard module
|
||||||
|
- `frontend/src/pages/CreatorDashboard.tsx` — Replaced placeholders with real dashboard: stat cards, techniques table, videos table, loading/error/empty states
|
||||||
|
- `frontend/src/pages/CreatorDashboard.module.css` — Styles for stat cards, data tables, badges, responsive mobile layout
|
||||||
64
.gsd/milestones/M020/slices/S02/S02-UAT.md
Normal file
64
.gsd/milestones/M020/slices/S02/S02-UAT.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# S02: [A] Creator Dashboard with Real Analytics — UAT
|
||||||
|
|
||||||
|
**Milestone:** M020
|
||||||
|
**Written:** 2026-04-04T00:15:19.382Z
|
||||||
|
|
||||||
|
## UAT: Creator Dashboard with Real Analytics
|
||||||
|
|
||||||
|
### Preconditions
|
||||||
|
- Chrysopedia stack running on ub01 (API + Web + DB)
|
||||||
|
- A user account exists with a linked `creator_id` (e.g., test creator with videos/techniques in DB)
|
||||||
|
- A second user account exists with `creator_id = NULL` (or no creator link)
|
||||||
|
- Valid JWT tokens for both users
|
||||||
|
|
||||||
|
### Test Cases
|
||||||
|
|
||||||
|
#### TC1: Authenticated dashboard returns correct stats
|
||||||
|
1. Obtain a JWT for a user with a linked creator
|
||||||
|
2. `curl -s -H 'Authorization: Bearer $TOKEN' http://ub01:8096/api/v1/creator/dashboard | python3 -m json.tool`
|
||||||
|
3. **Expected:** JSON response with `video_count`, `technique_count`, `key_moment_count`, `search_impressions` (integers), `techniques` (array of objects with title, slug, topic_category, created_at, key_moment_count), `videos` (array of objects with filename, processing_status, created_at)
|
||||||
|
4. Verify counts match actual DB content for that creator
|
||||||
|
|
||||||
|
#### TC2: Unauthenticated request rejected
|
||||||
|
1. `curl -s -o /dev/null -w '%{http_code}' http://ub01:8096/api/v1/creator/dashboard`
|
||||||
|
2. **Expected:** 401
|
||||||
|
|
||||||
|
#### TC3: User without creator_id gets 404
|
||||||
|
1. Obtain a JWT for a user with no linked creator (creator_id is NULL)
|
||||||
|
2. `curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer $TOKEN' http://ub01:8096/api/v1/creator/dashboard`
|
||||||
|
3. **Expected:** 404 with message "No creator profile linked to this account"
|
||||||
|
|
||||||
|
#### TC4: Frontend renders stat cards
|
||||||
|
1. Log in as a creator user at http://ub01:8096
|
||||||
|
2. Navigate to the creator dashboard page
|
||||||
|
3. **Expected:** 4 stat cards visible — Uploads, Techniques, Key Moments, Search Impressions — each showing a number and label
|
||||||
|
|
||||||
|
#### TC5: Frontend techniques table populated
|
||||||
|
1. On the dashboard page (logged in as creator with techniques)
|
||||||
|
2. **Expected:** Techniques section shows a table (desktop) or cards (mobile) with columns: title (clickable link), category badge, key moments count, date
|
||||||
|
3. Click a technique title
|
||||||
|
4. **Expected:** Navigates to `/techniques/{slug}` for that technique
|
||||||
|
|
||||||
|
#### TC6: Frontend videos table populated
|
||||||
|
1. On the dashboard page (logged in as creator with videos)
|
||||||
|
2. **Expected:** Videos section shows filename, processing status badge (color-coded), created date
|
||||||
|
|
||||||
|
#### TC7: Loading state
|
||||||
|
1. Throttle network to slow 3G in browser DevTools
|
||||||
|
2. Navigate to dashboard
|
||||||
|
3. **Expected:** Loading skeleton or spinner shown while data fetches
|
||||||
|
|
||||||
|
#### TC8: Empty/not-linked state
|
||||||
|
1. Log in as a user with no linked creator
|
||||||
|
2. Navigate to dashboard
|
||||||
|
3. **Expected:** Friendly message indicating no creator profile is linked — not a raw error
|
||||||
|
|
||||||
|
#### TC9: Mobile responsive layout
|
||||||
|
1. Set viewport to 375px width
|
||||||
|
2. Navigate to dashboard (logged in as creator)
|
||||||
|
3. **Expected:** Stat cards stack in a single column. Content tables render as stacked cards, not a horizontal table
|
||||||
|
|
||||||
|
#### TC10: Search impressions accuracy
|
||||||
|
1. Add entries to `search_log` table where `LOWER(query)` exactly matches a technique page title for the test creator
|
||||||
|
2. Refresh dashboard
|
||||||
|
3. **Expected:** Search Impressions stat card shows the count of matching search log entries
|
||||||
30
.gsd/milestones/M020/slices/S02/tasks/T02-VERIFY.json
Normal file
30
.gsd/milestones/M020/slices/S02/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T02",
|
||||||
|
"unitId": "M020/S02/T02",
|
||||||
|
"timestamp": 1775261628865,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd frontend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 4,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "npx tsc --noEmit",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 803,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "npm run build — both pass with zero errors. Visual check: dashboard page renders stat cards with numbers and content library table.",
|
||||||
|
"exitCode": 254,
|
||||||
|
"durationMs": 95,
|
||||||
|
"verdict": "fail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,61 @@
|
||||||
# S03: [A] Consent Dashboard UI
|
# S03: [A] Consent Dashboard UI
|
||||||
|
|
||||||
**Goal:** Build the frontend for consent management using the M019 consent API
|
**Goal:** Creator can view and toggle per-video consent settings (kb_inclusion, training_usage, public_display) through the creator dashboard, with audit history visible per video.
|
||||||
**Demo:** After this: Creator can toggle per-video consent settings (KB, AI training, shorts, embedding) through the dashboard
|
**Demo:** After this: Creator can toggle per-video consent settings (KB, AI training, shorts, embedding) through the dashboard
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
- [x] **T01: Created TypeScript consent API client with 5 fetch functions and a reusable accessible ToggleSwitch component with CSS module styling** — Create the TypeScript API client with types mirroring backend consent schemas, and a reusable ToggleSwitch component. These are independent building blocks that T02 consumes.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Create `frontend/src/api/consent.ts` with TypeScript interfaces matching backend schemas: `VideoConsentRead` (id, video_id, video_title, kb_inclusion, training_usage, public_display, updated_at, updated_by), `ConsentAuditEntry` (id, video_id, field_name, old_value, new_value, changed_by, changed_at), `ConsentListResponse` (items: VideoConsentRead[], total: number). Add fetch functions: `fetchConsentList(token)` calling GET /api/v1/consent/videos, `fetchVideoConsent(token, videoId)` calling GET /api/v1/consent/videos/{videoId}, `updateVideoConsent(token, videoId, updates)` calling PUT /api/v1/consent/videos/{videoId}, `fetchConsentHistory(token, videoId)` calling GET /api/v1/consent/videos/{videoId}/history. Use `request<T>()` from `api/client.ts` with `BASE` prefix.
|
||||||
|
|
||||||
|
2. Create `frontend/src/components/ToggleSwitch.tsx` — a reusable toggle component with props: `checked: boolean`, `onChange: (checked: boolean) => void`, `label: string`, `disabled?: boolean`, `id: string`. Render an accessible checkbox input visually styled as a sliding toggle. Use a CSS module `ToggleSwitch.module.css` with a track (40×22px), sliding circle thumb, checked state colors using existing CSS custom properties (--color-accent for on, --color-surface-2 for off), smooth 200ms transition, and disabled opacity. The input must have aria-label and the visual label must be associated.
|
||||||
|
|
||||||
|
3. Export consent API functions from `frontend/src/api/index.ts`.
|
||||||
|
|
||||||
|
## Must-Haves
|
||||||
|
|
||||||
|
- [ ] TypeScript types match the 3 consent boolean fields (kb_inclusion, training_usage, public_display)
|
||||||
|
- [ ] API functions use request<T>() from client.ts with proper Authorization header
|
||||||
|
- [ ] ToggleSwitch is accessible (checkbox input, aria-label, label association)
|
||||||
|
- [ ] ToggleSwitch has disabled state with visual feedback
|
||||||
|
- Estimate: 30m
|
||||||
|
- Files: frontend/src/api/consent.ts, frontend/src/components/ToggleSwitch.tsx, frontend/src/components/ToggleSwitch.module.css, frontend/src/api/index.ts
|
||||||
|
- Verify: cd frontend && npx tsc --noEmit && echo 'Types OK'
|
||||||
|
- [ ] **T02: Build ConsentDashboard page with route wiring and sidebar nav** — Build the main consent page, wire it into the app router and sidebar navigation. This is the user-facing deliverable.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Create `frontend/src/pages/ConsentDashboard.module.css` with styles for the consent page. Follow CreatorSettings.module.css patterns. Key classes: `.consentPage` container, `.videoRow` for each video's consent card, `.toggleRow` for label + toggle pairs, `.historyToggle` button to expand audit trail, `.historyList` for audit entries, `.emptyState` for no-videos message, `.loading` and `.error` states. Use CSS custom properties from the existing theme.
|
||||||
|
|
||||||
|
2. Create `frontend/src/pages/ConsentDashboard.tsx`:
|
||||||
|
- Import `SidebarNav` from `CreatorDashboard`, `dashStyles` from `CreatorDashboard.module.css`, `useAuth` from context, `useDocumentTitle` hook
|
||||||
|
- Import consent API functions from `../api/consent` and `ToggleSwitch` from `../components/ToggleSwitch`
|
||||||
|
- Layout: `<div className={dashStyles.layout}><SidebarNav /><div className={dashStyles.content}>` (matches CreatorSettings pattern)
|
||||||
|
- On mount, call `fetchConsentList(token)` to get all videos with consent state
|
||||||
|
- Render each video as a card with title, 3 ToggleSwitch components (KB Inclusion, Training Usage, Public Display)
|
||||||
|
- On toggle change, call `updateVideoConsent()` and update local state optimistically
|
||||||
|
- Each video card has an expandable "History" section that lazy-loads audit trail via `fetchConsentHistory()` on first expand
|
||||||
|
- Handle loading, error, and empty states
|
||||||
|
- Call `useDocumentTitle('Consent Settings')`
|
||||||
|
|
||||||
|
3. Update `frontend/src/pages/CreatorDashboard.tsx` SidebarNav to add a "Consent" link pointing to `/creator/consent`. Add a shield/lock SVG icon consistent with existing sidebar icons. Mark active when pathname is `/creator/consent`.
|
||||||
|
|
||||||
|
4. Update `frontend/src/App.tsx`:
|
||||||
|
- Add lazy import: `const ConsentDashboard = lazy(() => import('./pages/ConsentDashboard'))`
|
||||||
|
- Add route: `<Route path='/creator/consent' element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ConsentDashboard /></Suspense></ProtectedRoute>} />`
|
||||||
|
|
||||||
|
## Must-Haves
|
||||||
|
|
||||||
|
- [ ] ConsentDashboard uses shared SidebarNav + dashStyles.layout
|
||||||
|
- [ ] All 3 consent fields rendered as toggles per video
|
||||||
|
- [ ] Toggle changes call PUT endpoint and update UI
|
||||||
|
- [ ] Audit history expandable per video
|
||||||
|
- [ ] Route registered at /creator/consent with ProtectedRoute + Suspense
|
||||||
|
- [ ] Sidebar nav has Consent link with active state
|
||||||
|
- [ ] useDocumentTitle called
|
||||||
|
- [ ] Loading, error, and empty states handled
|
||||||
|
- Estimate: 1h
|
||||||
|
- Files: frontend/src/pages/ConsentDashboard.tsx, frontend/src/pages/ConsentDashboard.module.css, frontend/src/pages/CreatorDashboard.tsx, frontend/src/App.tsx
|
||||||
|
- Verify: cd frontend && npx tsc --noEmit && npm run build && echo 'Build OK'
|
||||||
|
|
|
||||||
81
.gsd/milestones/M020/slices/S03/S03-RESEARCH.md
Normal file
81
.gsd/milestones/M020/slices/S03/S03-RESEARCH.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# S03 Research — Consent Dashboard UI
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This is a **frontend-only** slice. The entire backend — model, migration, router, schemas, 21 integration tests — is already built and tested. The work is: build a React page that calls the existing consent API and integrate it into the creator dashboard sidebar.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Light implementation. Follow the CreatorSettings page pattern (shared sidebar layout, CSS modules, `useAuth` for token). Build a toggle switch component and a consent page. No backend changes needed.
|
||||||
|
|
||||||
|
## Implementation Landscape
|
||||||
|
|
||||||
|
### What Exists (Backend — Complete)
|
||||||
|
|
||||||
|
| Layer | File | Status |
|
||||||
|
|-------|------|--------|
|
||||||
|
| Model | `backend/models.py` lines 573-655 | `VideoConsent`, `ConsentAuditLog`, `ConsentField` enum |
|
||||||
|
| Migration | `alembic/versions/017_add_consent_tables.py` | Tables: `video_consents`, `consent_audit_log` |
|
||||||
|
| Router | `backend/routers/consent.py` | 5 endpoints, ownership-gated, audit logged |
|
||||||
|
| Schemas | `backend/schemas.py` lines 578-625 | `VideoConsentUpdate`, `VideoConsentRead`, `ConsentAuditEntry`, `ConsentListResponse`, `ConsentSummary` |
|
||||||
|
| Tests | `backend/tests/test_consent.py` | 21 tests covering auth, CRUD, audit, pagination, admin |
|
||||||
|
| Registration | `backend/main.py:82` | `app.include_router(consent.router, prefix="/api/v1")` |
|
||||||
|
|
||||||
|
**API Endpoints:**
|
||||||
|
- `GET /api/v1/consent/videos` — list consent for current creator's videos (paginated)
|
||||||
|
- `GET /api/v1/consent/videos/{video_id}` — single video consent
|
||||||
|
- `PUT /api/v1/consent/videos/{video_id}` — upsert consent (partial update, audit logged)
|
||||||
|
- `GET /api/v1/consent/videos/{video_id}/history` — audit trail
|
||||||
|
- `GET /api/v1/consent/admin/summary` — aggregate counts (admin only)
|
||||||
|
|
||||||
|
### Consent Flags (3, Not 4)
|
||||||
|
|
||||||
|
The roadmap description says "(KB, AI training, shorts, embedding)" — 4 toggles. The actual schema has **3 boolean fields**:
|
||||||
|
- `kb_inclusion` (default false) — include in knowledge base
|
||||||
|
- `training_usage` (default false) — allow AI training usage
|
||||||
|
- `public_display` (default true) — show publicly
|
||||||
|
|
||||||
|
No `shorts_clips` or `embedding` columns exist. The planner should build the UI for the 3 fields that exist, not the 4 the roadmap aspirationally describes.
|
||||||
|
|
||||||
|
### What Exists (Frontend — Dashboard Shell)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/src/pages/CreatorDashboard.tsx` | Main dashboard with `SidebarNav` (exported), stats, techniques table, videos table |
|
||||||
|
| `frontend/src/pages/CreatorDashboard.module.css` | 377-line module with layout, sidebar, stat cards, tables, mobile cards, badges |
|
||||||
|
| `frontend/src/pages/CreatorSettings.tsx` | Settings page — **pattern to follow** — uses shared `SidebarNav` + `dashStyles.layout` |
|
||||||
|
| `frontend/src/pages/CreatorSettings.module.css` | 115-line module with form styles |
|
||||||
|
| `frontend/src/api/creator-dashboard.ts` | API client — `request()` from `client.ts` with Bearer token auto-injection |
|
||||||
|
| `frontend/src/api/client.ts` | Shared `request<T>()` helper, `BASE="/api/v1"`, `ApiError` class, auto `Authorization` header |
|
||||||
|
|
||||||
|
**Sidebar nav** currently has: Dashboard (active), Content (disabled/coming-soon), Settings. The consent page needs a new nav link.
|
||||||
|
|
||||||
|
**Routing** is in `frontend/src/App.tsx` — ProtectedRoute + Suspense pattern at `/creator/dashboard` and `/creator/settings`.
|
||||||
|
|
||||||
|
### What Needs Building
|
||||||
|
|
||||||
|
1. **API client** (`frontend/src/api/consent.ts`) — types + fetch functions for the 5 consent endpoints
|
||||||
|
2. **Toggle switch component** (`frontend/src/components/ToggleSwitch.tsx` + CSS module) — no existing toggle in the codebase
|
||||||
|
3. **Consent page** (`frontend/src/pages/ConsentDashboard.tsx` + CSS module) — list videos with toggle rows, expandable audit history
|
||||||
|
4. **Sidebar nav update** — add "Consent" link to `SidebarNav` in `CreatorDashboard.tsx`
|
||||||
|
5. **Route registration** — add `/creator/consent` route in `App.tsx`
|
||||||
|
|
||||||
|
### Patterns to Follow
|
||||||
|
|
||||||
|
- **Layout**: `<div className={dashStyles.layout}><SidebarNav /><div className={dashStyles.content}>` (see CreatorSettings)
|
||||||
|
- **Data fetching**: `useEffect` + `useState` + `request<T>()` from `api/client.ts`
|
||||||
|
- **Error handling**: `ApiError` class with `.status` and `.detail`
|
||||||
|
- **Document title**: `useDocumentTitle("Consent Settings")`
|
||||||
|
- **Lazy loading**: `const ConsentDashboard = lazy(() => import("./pages/ConsentDashboard"))` in App.tsx
|
||||||
|
|
||||||
|
### Natural Task Seams
|
||||||
|
|
||||||
|
1. **T01: API client + types** — `frontend/src/api/consent.ts` with TS interfaces mirroring the backend schemas and fetch functions. Small, independent, unblocks T03.
|
||||||
|
2. **T02: Toggle switch component** — Reusable `ToggleSwitch` with label, checked, onChange, disabled props. CSS module. Independent of consent-specific code.
|
||||||
|
3. **T03: Consent page + route wiring** — The main page component, CSS module, sidebar nav link, App.tsx route. Depends on T01 and T02.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `cd frontend && npx tsc --noEmit` — zero type errors
|
||||||
|
- `cd frontend && npm run build` — production build succeeds
|
||||||
|
- Visual verification: navigate to `/creator/consent` in browser, see video list with toggles, toggle a flag, verify it persists on reload, check audit history expands
|
||||||
39
.gsd/milestones/M020/slices/S03/tasks/T01-PLAN.md
Normal file
39
.gsd/milestones/M020/slices/S03/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
estimated_steps: 10
|
||||||
|
estimated_files: 4
|
||||||
|
skills_used: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Build consent API client and ToggleSwitch component
|
||||||
|
|
||||||
|
Create the TypeScript API client with types mirroring backend consent schemas, and a reusable ToggleSwitch component. These are independent building blocks that T02 consumes.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Create `frontend/src/api/consent.ts` with TypeScript interfaces matching backend schemas: `VideoConsentRead` (id, video_id, video_title, kb_inclusion, training_usage, public_display, updated_at, updated_by), `ConsentAuditEntry` (id, video_id, field_name, old_value, new_value, changed_by, changed_at), `ConsentListResponse` (items: VideoConsentRead[], total: number). Add fetch functions: `fetchConsentList(token)` calling GET /api/v1/consent/videos, `fetchVideoConsent(token, videoId)` calling GET /api/v1/consent/videos/{videoId}, `updateVideoConsent(token, videoId, updates)` calling PUT /api/v1/consent/videos/{videoId}, `fetchConsentHistory(token, videoId)` calling GET /api/v1/consent/videos/{videoId}/history. Use `request<T>()` from `api/client.ts` with `BASE` prefix.
|
||||||
|
|
||||||
|
2. Create `frontend/src/components/ToggleSwitch.tsx` — a reusable toggle component with props: `checked: boolean`, `onChange: (checked: boolean) => void`, `label: string`, `disabled?: boolean`, `id: string`. Render an accessible checkbox input visually styled as a sliding toggle. Use a CSS module `ToggleSwitch.module.css` with a track (40×22px), sliding circle thumb, checked state colors using existing CSS custom properties (--color-accent for on, --color-surface-2 for off), smooth 200ms transition, and disabled opacity. The input must have aria-label and the visual label must be associated.
|
||||||
|
|
||||||
|
3. Export consent API functions from `frontend/src/api/index.ts`.
|
||||||
|
|
||||||
|
## Must-Haves
|
||||||
|
|
||||||
|
- [ ] TypeScript types match the 3 consent boolean fields (kb_inclusion, training_usage, public_display)
|
||||||
|
- [ ] API functions use request<T>() from client.ts with proper Authorization header
|
||||||
|
- [ ] ToggleSwitch is accessible (checkbox input, aria-label, label association)
|
||||||
|
- [ ] ToggleSwitch has disabled state with visual feedback
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- `frontend/src/api/client.ts`
|
||||||
|
- `backend/schemas.py`
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
- `frontend/src/api/consent.ts`
|
||||||
|
- `frontend/src/components/ToggleSwitch.tsx`
|
||||||
|
- `frontend/src/components/ToggleSwitch.module.css`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
cd frontend && npx tsc --noEmit && echo 'Types OK'
|
||||||
81
.gsd/milestones/M020/slices/S03/tasks/T01-SUMMARY.md
Normal file
81
.gsd/milestones/M020/slices/S03/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
---
|
||||||
|
id: T01
|
||||||
|
parent: S03
|
||||||
|
milestone: M020
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/api/consent.ts", "frontend/src/components/ToggleSwitch.tsx", "frontend/src/components/ToggleSwitch.module.css", "frontend/src/api/index.ts"]
|
||||||
|
key_decisions: ["Followed existing pattern of no explicit token param — request() auto-injects from localStorage", "Used data-attributes for state styling (data-checked, data-disabled) for cleaner CSS selectors"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "TypeScript compilation passed with zero errors: cd frontend && npx tsc --noEmit"
|
||||||
|
completed_at: 2026-04-04T00:21:09.776Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Created TypeScript consent API client with 5 fetch functions and a reusable accessible ToggleSwitch component with CSS module styling
|
||||||
|
|
||||||
|
> Created TypeScript consent API client with 5 fetch functions and a reusable accessible ToggleSwitch component with CSS module styling
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T01
|
||||||
|
parent: S03
|
||||||
|
milestone: M020
|
||||||
|
key_files:
|
||||||
|
- frontend/src/api/consent.ts
|
||||||
|
- frontend/src/components/ToggleSwitch.tsx
|
||||||
|
- frontend/src/components/ToggleSwitch.module.css
|
||||||
|
- frontend/src/api/index.ts
|
||||||
|
key_decisions:
|
||||||
|
- Followed existing pattern of no explicit token param — request() auto-injects from localStorage
|
||||||
|
- Used data-attributes for state styling (data-checked, data-disabled) for cleaner CSS selectors
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-04T00:21:09.776Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Created TypeScript consent API client with 5 fetch functions and a reusable accessible ToggleSwitch component with CSS module styling
|
||||||
|
|
||||||
|
**Created TypeScript consent API client with 5 fetch functions and a reusable accessible ToggleSwitch component with CSS module styling**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Built two independent building blocks for the consent dashboard: (1) consent.ts API client with types mirroring backend schemas and 5 fetch functions using the established request<T>() pattern with auto-injected auth, (2) ToggleSwitch component with accessible checkbox (role=switch, aria-label, label association), CSS module styling using project color tokens, 200ms slide transition, and disabled state. Exported consent module from the api barrel.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
TypeScript compilation passed with zero errors: cd frontend && npx tsc --noEmit
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 8000ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
Added ConsentSummary type and fetchConsentSummary() not in original plan — backend schema defines it. Used data-attributes for state styling instead of className toggling.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/api/consent.ts`
|
||||||
|
- `frontend/src/components/ToggleSwitch.tsx`
|
||||||
|
- `frontend/src/components/ToggleSwitch.module.css`
|
||||||
|
- `frontend/src/api/index.ts`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
Added ConsentSummary type and fetchConsentSummary() not in original plan — backend schema defines it. Used data-attributes for state styling instead of className toggling.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
63
.gsd/milestones/M020/slices/S03/tasks/T02-PLAN.md
Normal file
63
.gsd/milestones/M020/slices/S03/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
---
|
||||||
|
estimated_steps: 26
|
||||||
|
estimated_files: 4
|
||||||
|
skills_used: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Build ConsentDashboard page with route wiring and sidebar nav
|
||||||
|
|
||||||
|
Build the main consent page, wire it into the app router and sidebar navigation. This is the user-facing deliverable.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Create `frontend/src/pages/ConsentDashboard.module.css` with styles for the consent page. Follow CreatorSettings.module.css patterns. Key classes: `.consentPage` container, `.videoRow` for each video's consent card, `.toggleRow` for label + toggle pairs, `.historyToggle` button to expand audit trail, `.historyList` for audit entries, `.emptyState` for no-videos message, `.loading` and `.error` states. Use CSS custom properties from the existing theme.
|
||||||
|
|
||||||
|
2. Create `frontend/src/pages/ConsentDashboard.tsx`:
|
||||||
|
- Import `SidebarNav` from `CreatorDashboard`, `dashStyles` from `CreatorDashboard.module.css`, `useAuth` from context, `useDocumentTitle` hook
|
||||||
|
- Import consent API functions from `../api/consent` and `ToggleSwitch` from `../components/ToggleSwitch`
|
||||||
|
- Layout: `<div className={dashStyles.layout}><SidebarNav /><div className={dashStyles.content}>` (matches CreatorSettings pattern)
|
||||||
|
- On mount, call `fetchConsentList(token)` to get all videos with consent state
|
||||||
|
- Render each video as a card with title, 3 ToggleSwitch components (KB Inclusion, Training Usage, Public Display)
|
||||||
|
- On toggle change, call `updateVideoConsent()` and update local state optimistically
|
||||||
|
- Each video card has an expandable "History" section that lazy-loads audit trail via `fetchConsentHistory()` on first expand
|
||||||
|
- Handle loading, error, and empty states
|
||||||
|
- Call `useDocumentTitle('Consent Settings')`
|
||||||
|
|
||||||
|
3. Update `frontend/src/pages/CreatorDashboard.tsx` SidebarNav to add a "Consent" link pointing to `/creator/consent`. Add a shield/lock SVG icon consistent with existing sidebar icons. Mark active when pathname is `/creator/consent`.
|
||||||
|
|
||||||
|
4. Update `frontend/src/App.tsx`:
|
||||||
|
- Add lazy import: `const ConsentDashboard = lazy(() => import('./pages/ConsentDashboard'))`
|
||||||
|
- Add route: `<Route path='/creator/consent' element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ConsentDashboard /></Suspense></ProtectedRoute>} />`
|
||||||
|
|
||||||
|
## Must-Haves
|
||||||
|
|
||||||
|
- [ ] ConsentDashboard uses shared SidebarNav + dashStyles.layout
|
||||||
|
- [ ] All 3 consent fields rendered as toggles per video
|
||||||
|
- [ ] Toggle changes call PUT endpoint and update UI
|
||||||
|
- [ ] Audit history expandable per video
|
||||||
|
- [ ] Route registered at /creator/consent with ProtectedRoute + Suspense
|
||||||
|
- [ ] Sidebar nav has Consent link with active state
|
||||||
|
- [ ] useDocumentTitle called
|
||||||
|
- [ ] Loading, error, and empty states handled
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- `frontend/src/api/consent.ts`
|
||||||
|
- `frontend/src/components/ToggleSwitch.tsx`
|
||||||
|
- `frontend/src/components/ToggleSwitch.module.css`
|
||||||
|
- `frontend/src/pages/CreatorDashboard.tsx`
|
||||||
|
- `frontend/src/pages/CreatorDashboard.module.css`
|
||||||
|
- `frontend/src/pages/CreatorSettings.tsx`
|
||||||
|
- `frontend/src/App.tsx`
|
||||||
|
- `frontend/src/context/AuthContext.tsx`
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
- `frontend/src/pages/ConsentDashboard.tsx`
|
||||||
|
- `frontend/src/pages/ConsentDashboard.module.css`
|
||||||
|
- `frontend/src/pages/CreatorDashboard.tsx`
|
||||||
|
- `frontend/src/App.tsx`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
cd frontend && npx tsc --noEmit && npm run build && echo 'Build OK'
|
||||||
78
frontend/src/api/consent.ts
Normal file
78
frontend/src/api/consent.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* Consent API client — per-video consent settings and audit history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request, BASE } from "./client";
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface VideoConsentRead {
|
||||||
|
source_video_id: string;
|
||||||
|
video_filename: string;
|
||||||
|
creator_id: string;
|
||||||
|
kb_inclusion: boolean;
|
||||||
|
training_usage: boolean;
|
||||||
|
public_display: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoConsentUpdate {
|
||||||
|
kb_inclusion?: boolean;
|
||||||
|
training_usage?: boolean;
|
||||||
|
public_display?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentAuditEntry {
|
||||||
|
version: number;
|
||||||
|
field_name: string;
|
||||||
|
old_value: boolean | null;
|
||||||
|
new_value: boolean;
|
||||||
|
changed_by: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentListResponse {
|
||||||
|
items: VideoConsentRead[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentSummary {
|
||||||
|
total_videos: number;
|
||||||
|
kb_inclusion_granted: number;
|
||||||
|
training_usage_granted: number;
|
||||||
|
public_display_granted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Functions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchConsentList(): Promise<ConsentListResponse> {
|
||||||
|
return request<ConsentListResponse>(`${BASE}/consent/videos`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVideoConsent(
|
||||||
|
videoId: string,
|
||||||
|
): Promise<VideoConsentRead> {
|
||||||
|
return request<VideoConsentRead>(`${BASE}/consent/videos/${videoId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVideoConsent(
|
||||||
|
videoId: string,
|
||||||
|
updates: VideoConsentUpdate,
|
||||||
|
): Promise<VideoConsentRead> {
|
||||||
|
return request<VideoConsentRead>(`${BASE}/consent/videos/${videoId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConsentHistory(
|
||||||
|
videoId: string,
|
||||||
|
): Promise<ConsentAuditEntry[]> {
|
||||||
|
return request<ConsentAuditEntry[]>(
|
||||||
|
`${BASE}/consent/videos/${videoId}/history`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConsentSummary(): Promise<ConsentSummary> {
|
||||||
|
return request<ConsentSummary>(`${BASE}/consent/summary`);
|
||||||
|
}
|
||||||
|
|
@ -14,3 +14,4 @@ export * from "./admin-pipeline";
|
||||||
export * from "./admin-techniques";
|
export * from "./admin-techniques";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./creator-dashboard";
|
export * from "./creator-dashboard";
|
||||||
|
export * from "./consent";
|
||||||
|
|
|
||||||
71
frontend/src/components/ToggleSwitch.module.css
Normal file
71
frontend/src/components/ToggleSwitch.module.css
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/* ToggleSwitch — sliding toggle with accessible checkbox */
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper[data-disabled] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track (pill shape) */
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--color-border);
|
||||||
|
transition: background 200ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track[data-checked] {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually hidden native checkbox */
|
||||||
|
.input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliding thumb */
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-primary);
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track[data-checked] .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring on the track when input is focused */
|
||||||
|
.input:focus-visible ~ .thumb {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-focus);
|
||||||
|
}
|
||||||
44
frontend/src/components/ToggleSwitch.tsx
Normal file
44
frontend/src/components/ToggleSwitch.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Reusable toggle switch — accessible checkbox styled as a sliding toggle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
import styles from "./ToggleSwitch.module.css";
|
||||||
|
|
||||||
|
export interface ToggleSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleSwitch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
id,
|
||||||
|
}: ToggleSwitchProps) {
|
||||||
|
const autoId = useId();
|
||||||
|
const inputId = id ?? autoId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={styles.wrapper} htmlFor={inputId} data-disabled={disabled || undefined}>
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
<span className={styles.track} data-checked={checked || undefined}>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
className={styles.input}
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={styles.thumb} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue