From 4115c8add06059720530d9d96e11c9996d305501 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 00:24:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20ConsentDashboard=20page=20with?= =?UTF-8?q?=20per-video=20consent=20toggles,=20expan=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/pages/ConsentDashboard.tsx" - "frontend/src/pages/ConsentDashboard.module.css" - "frontend/src/pages/CreatorDashboard.tsx" - "frontend/src/App.tsx" GSD-Task: S03/T02 --- .gsd/milestones/M020/slices/S03/S03-PLAN.md | 2 +- .../M020/slices/S03/tasks/T01-VERIFY.json | 30 ++ .../M020/slices/S03/tasks/T02-SUMMARY.md | 83 +++++ frontend/src/App.tsx | 2 + .../src/pages/ConsentDashboard.module.css | 144 ++++++++ frontend/src/pages/ConsentDashboard.tsx | 329 ++++++++++++++++++ frontend/src/pages/CreatorDashboard.tsx | 7 + frontend/tsconfig.app.tsbuildinfo | 2 +- 8 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 .gsd/milestones/M020/slices/S03/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M020/slices/S03/tasks/T02-SUMMARY.md create mode 100644 frontend/src/pages/ConsentDashboard.module.css create mode 100644 frontend/src/pages/ConsentDashboard.tsx diff --git a/.gsd/milestones/M020/slices/S03/S03-PLAN.md b/.gsd/milestones/M020/slices/S03/S03-PLAN.md index 03b0673..4428472 100644 --- a/.gsd/milestones/M020/slices/S03/S03-PLAN.md +++ b/.gsd/milestones/M020/slices/S03/S03-PLAN.md @@ -23,7 +23,7 @@ - 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. +- [x] **T02: Built ConsentDashboard page with per-video consent toggles, expandable audit history, optimistic updates, and wired it into the router and sidebar navigation** — Build the main consent page, wire it into the app router and sidebar navigation. This is the user-facing deliverable. ## Steps diff --git a/.gsd/milestones/M020/slices/S03/tasks/T01-VERIFY.json b/.gsd/milestones/M020/slices/S03/tasks/T01-VERIFY.json new file mode 100644 index 0000000..ccf6f6a --- /dev/null +++ b/.gsd/milestones/M020/slices/S03/tasks/T01-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M020/S03/T01", + "timestamp": 1775262073341, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 4, + "verdict": "pass" + }, + { + "command": "npx tsc --noEmit", + "exitCode": 1, + "durationMs": 804, + "verdict": "fail" + }, + { + "command": "echo 'Types OK'", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M020/slices/S03/tasks/T02-SUMMARY.md b/.gsd/milestones/M020/slices/S03/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..7908c0d --- /dev/null +++ b/.gsd/milestones/M020/slices/S03/tasks/T02-SUMMARY.md @@ -0,0 +1,83 @@ +--- +id: T02 +parent: S03 +milestone: M020 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/pages/ConsentDashboard.tsx", "frontend/src/pages/ConsentDashboard.module.css", "frontend/src/pages/CreatorDashboard.tsx", "frontend/src/App.tsx"] +key_decisions: ["Used padlock SVG icon for Consent sidebar link", "Stored per-card state in single cards array for simplicity", "Optimistic toggle update with revert on API error"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "TypeScript type checking (tsc --noEmit) and Vite production build (npm run build) both pass with exit code 0. ConsentDashboard is code-split into its own chunk." +completed_at: 2026-04-04T00:24:14.390Z +blocker_discovered: false +--- + +# T02: Built ConsentDashboard page with per-video consent toggles, expandable audit history, optimistic updates, and wired it into the router and sidebar navigation + +> Built ConsentDashboard page with per-video consent toggles, expandable audit history, optimistic updates, and wired it into the router and sidebar navigation + +## What Happened +--- +id: T02 +parent: S03 +milestone: M020 +key_files: + - frontend/src/pages/ConsentDashboard.tsx + - frontend/src/pages/ConsentDashboard.module.css + - frontend/src/pages/CreatorDashboard.tsx + - frontend/src/App.tsx +key_decisions: + - Used padlock SVG icon for Consent sidebar link + - Stored per-card state in single cards array for simplicity + - Optimistic toggle update with revert on API error +duration: "" +verification_result: passed +completed_at: 2026-04-04T00:24:14.390Z +blocker_discovered: false +--- + +# T02: Built ConsentDashboard page with per-video consent toggles, expandable audit history, optimistic updates, and wired it into the router and sidebar navigation + +**Built ConsentDashboard page with per-video consent toggles, expandable audit history, optimistic updates, and wired it into the router and sidebar navigation** + +## What Happened + +Created the ConsentDashboard page component with full state management for video consent toggles. Each video renders as a card with three ToggleSwitch components (KB Inclusion, AI Training Usage, Public Display). Toggles use optimistic updates with revert on API error. Each card has a lazy-loaded audit history section. Added padlock icon to sidebar nav and wired the route in App.tsx with ProtectedRoute + Suspense. + +## Verification + +TypeScript type checking (tsc --noEmit) and Vite production build (npm run build) both pass with exit code 0. ConsentDashboard is code-split into its own chunk. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `npx tsc --noEmit` | 0 | ✅ pass | 3000ms | +| 2 | `npm run build` | 0 | ✅ pass | 2000ms | + + +## Deviations + +None. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/pages/ConsentDashboard.tsx` +- `frontend/src/pages/ConsentDashboard.module.css` +- `frontend/src/pages/CreatorDashboard.tsx` +- `frontend/src/App.tsx` + + +## Deviations +None. + +## Known Issues +None. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a51162e..47e329c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ const AdminTechniquePages = React.lazy(() => import("./pages/AdminTechniquePages const About = React.lazy(() => import("./pages/About")); const CreatorDashboard = React.lazy(() => import("./pages/CreatorDashboard")); const CreatorSettings = React.lazy(() => import("./pages/CreatorSettings")); +const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard")); const WatchPage = React.lazy(() => import("./pages/WatchPage")); import AdminDropdown from "./components/AdminDropdown"; import AppFooter from "./components/AppFooter"; @@ -188,6 +189,7 @@ function AppShell() { {/* Creator routes (protected) */} }>} /> + }>} /> }>} /> {/* Fallback */} diff --git a/frontend/src/pages/ConsentDashboard.module.css b/frontend/src/pages/ConsentDashboard.module.css new file mode 100644 index 0000000..dad93f6 --- /dev/null +++ b/frontend/src/pages/ConsentDashboard.module.css @@ -0,0 +1,144 @@ +/* ── Consent Dashboard ─────────────────────────────────────────────────────── */ + +.heading { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 1.5rem; +} + +/* ── Video consent cards ───────────────────────────────────────────────────── */ + +.videoRow { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 10px; + padding: 1.25rem 1.5rem; + margin-bottom: 1rem; +} + +.videoTitle { + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 1rem; +} + +.toggleGroup { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.toggleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.updateError { + margin-top: 0.5rem; + font-size: 0.8125rem; + color: #f87171; +} + +/* ── Audit history ─────────────────────────────────────────────────────────── */ + +.historyToggle { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin-top: 0.75rem; + padding: 0; + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 0.8125rem; + cursor: pointer; + transition: color 0.15s; +} + +.historyToggle:hover { + color: var(--color-text-primary); +} + +.historyArrow { + display: inline-block; + transition: transform 0.2s; + font-size: 0.75rem; +} + +.historyArrowOpen { + transform: rotate(90deg); +} + +.historyList { + margin-top: 0.5rem; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.historyEntry { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; + font-size: 0.8125rem; + color: var(--color-text-secondary); + padding: 0.375rem 0.5rem; + background: var(--color-bg-input, rgba(255, 255, 255, 0.03)); + border-radius: 6px; +} + +.historyField { + font-weight: 600; + color: var(--color-text-primary); +} + +.historyDate { + margin-left: auto; + font-size: 0.75rem; + opacity: 0.7; +} + +/* ── States ────────────────────────────────────────────────────────────────── */ + +.loading { + display: flex; + justify-content: center; + padding: 3rem 0; + color: var(--color-text-secondary); + font-size: 0.9375rem; +} + +.error { + background: rgba(239, 68, 68, 0.1); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; + padding: 0.75rem; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.emptyState { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-secondary); +} + +.emptyState h2 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 0.5rem; +} + +.emptyState p { + margin: 0; + font-size: 0.9375rem; +} diff --git a/frontend/src/pages/ConsentDashboard.tsx b/frontend/src/pages/ConsentDashboard.tsx new file mode 100644 index 0000000..555ce88 --- /dev/null +++ b/frontend/src/pages/ConsentDashboard.tsx @@ -0,0 +1,329 @@ +/** + * ConsentDashboard — per-video consent toggle page for creators. + * + * Shows all videos with three consent toggles each (KB inclusion, + * Training usage, Public display). Each card has an expandable + * audit history section loaded on first expand. + */ + +import { useEffect, useState, useCallback } from "react"; +import { SidebarNav } from "./CreatorDashboard"; +import dashStyles from "./CreatorDashboard.module.css"; +import styles from "./ConsentDashboard.module.css"; +import { useAuth } from "../context/AuthContext"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import { ToggleSwitch } from "../components/ToggleSwitch"; +import { + fetchConsentList, + updateVideoConsent, + fetchConsentHistory, + type VideoConsentRead, + type ConsentAuditEntry, +} from "../api/consent"; +import { ApiError } from "../api/client"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type ConsentField = "kb_inclusion" | "training_usage" | "public_display"; + +interface VideoCardState { + consent: VideoConsentRead; + historyOpen: boolean; + historyLoaded: boolean; + historyLoading: boolean; + history: ConsentAuditEntry[]; + updating: ConsentField | null; + updateError: string | null; +} + +// ── Field labels ───────────────────────────────────────────────────────────── + +const CONSENT_FIELDS: { key: ConsentField; label: string }[] = [ + { key: "kb_inclusion", label: "Knowledge Base Inclusion" }, + { key: "training_usage", label: "AI Training Usage" }, + { key: "public_display", label: "Public Display" }, +]; + +// ── Main component ─────────────────────────────────────────────────────────── + +export default function ConsentDashboard() { + useDocumentTitle("Consent Settings"); + const { user: _user } = useAuth(); + + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // ── Fetch consent list on mount ────────────────────────────────────────── + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + fetchConsentList() + .then((res) => { + if (cancelled) return; + setCards( + res.items.map((consent) => ({ + consent, + historyOpen: false, + historyLoaded: false, + historyLoading: false, + history: [], + updating: null, + updateError: null, + })), + ); + }) + .catch((err) => { + if (cancelled) return; + setError( + err instanceof ApiError + ? err.detail + : "Failed to load consent settings", + ); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + // ── Toggle handler ──────────────────────────────────────────────────────── + + const handleToggle = useCallback( + async (videoId: string, field: ConsentField, newValue: boolean) => { + // Optimistic update + setCards((prev) => + prev.map((c) => + c.consent.source_video_id === videoId + ? { + ...c, + consent: { ...c.consent, [field]: newValue }, + updating: field, + updateError: null, + } + : c, + ), + ); + + try { + const updated = await updateVideoConsent(videoId, { + [field]: newValue, + }); + setCards((prev) => + prev.map((c) => + c.consent.source_video_id === videoId + ? { ...c, consent: updated, updating: null } + : c, + ), + ); + } catch (err) { + // Revert optimistic update + setCards((prev) => + prev.map((c) => + c.consent.source_video_id === videoId + ? { + ...c, + consent: { ...c.consent, [field]: !newValue }, + updating: null, + updateError: + err instanceof ApiError + ? err.detail + : "Update failed — try again", + } + : c, + ), + ); + } + }, + [], + ); + + // ── History expand handler ──────────────────────────────────────────────── + + const toggleHistory = useCallback(async (videoId: string) => { + setCards((prev) => + prev.map((c) => { + if (c.consent.source_video_id !== videoId) return c; + if (c.historyOpen) return { ...c, historyOpen: false }; + // Opening — need to load if not yet loaded + if (c.historyLoaded) return { ...c, historyOpen: true }; + return { ...c, historyOpen: true, historyLoading: true }; + }), + ); + + // Check if we need to fetch + const card = cards.find((c) => c.consent.source_video_id === videoId); + if (!card || card.historyLoaded) return; + + try { + const history = await fetchConsentHistory(videoId); + setCards((prev) => + prev.map((c) => + c.consent.source_video_id === videoId + ? { ...c, history, historyLoaded: true, historyLoading: false } + : c, + ), + ); + } catch { + setCards((prev) => + prev.map((c) => + c.consent.source_video_id === videoId + ? { + ...c, + historyLoading: false, + history: [], + historyLoaded: true, + } + : c, + ), + ); + } + }, [cards]); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ +
+

Consent Settings

+ + {loading && ( +
Loading consent data…
+ )} + + {!loading && error &&
{error}
} + + {!loading && !error && cards.length === 0 && ( +
+

No Videos

+

+ No videos found for your creator profile. Upload content to manage + consent settings. +

+
+ )} + + {!loading && + !error && + cards.map((card) => ( + + ))} +
+
+ ); +} + +// ── Video card ─────────────────────────────────────────────────────────────── + +function VideoConsentCard({ + card, + onToggle, + onToggleHistory, +}: { + card: VideoCardState; + onToggle: ( + videoId: string, + field: ConsentField, + newValue: boolean, + ) => void; + onToggleHistory: (videoId: string) => void; +}) { + const { consent, historyOpen, historyLoading, history, updating, updateError } = + card; + const videoId = consent.source_video_id; + + return ( +
+

{consent.video_filename}

+ +
+ {CONSENT_FIELDS.map(({ key, label }) => ( +
+ onToggle(videoId, key, val)} + label={label} + disabled={updating === key} + /> +
+ ))} +
+ + {updateError &&

{updateError}

} + + + + {historyOpen && ( + <> + {historyLoading && ( +

+ Loading history… +

+ )} + {!historyLoading && history.length === 0 && ( +

+ No changes recorded yet. +

+ )} + {!historyLoading && history.length > 0 && ( +
    + {history.map((entry, i) => ( +
  • + + {entry.field_name} + + + {entry.old_value === null + ? "set to" + : entry.old_value + ? "on → off" + : "off → on"} + + by {entry.changed_by} + + {new Date(entry.created_at).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + +
  • + ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/pages/CreatorDashboard.tsx b/frontend/src/pages/CreatorDashboard.tsx index 927210b..de1fefc 100644 --- a/frontend/src/pages/CreatorDashboard.tsx +++ b/frontend/src/pages/CreatorDashboard.tsx @@ -35,6 +35,13 @@ function SidebarNav() { Content + + + + + + Consent + diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index a24504a..161dc45 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file