feat: Built ConsentDashboard page with per-video consent toggles, expan…

- "frontend/src/pages/ConsentDashboard.tsx"
- "frontend/src/pages/ConsentDashboard.module.css"
- "frontend/src/pages/CreatorDashboard.tsx"
- "frontend/src/App.tsx"

GSD-Task: S03/T02
This commit is contained in:
jlightner 2026-04-04 00:24:17 +00:00
parent 31638b5a3a
commit 4115c8add0
8 changed files with 597 additions and 2 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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.

View file

@ -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) */}
<Route path="/creator/dashboard" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorDashboard /></Suspense></ProtectedRoute>} />
<Route path="/creator/consent" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ConsentDashboard /></Suspense></ProtectedRoute>} />
<Route path="/creator/settings" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorSettings /></Suspense></ProtectedRoute>} />
{/* Fallback */}

View file

@ -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;
}

View file

@ -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<VideoCardState[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className={dashStyles.layout}>
<SidebarNav />
<div className={dashStyles.content}>
<h1 className={styles.heading}>Consent Settings</h1>
{loading && (
<div className={styles.loading}>Loading consent data</div>
)}
{!loading && error && <div className={styles.error}>{error}</div>}
{!loading && !error && cards.length === 0 && (
<div className={styles.emptyState}>
<h2>No Videos</h2>
<p>
No videos found for your creator profile. Upload content to manage
consent settings.
</p>
</div>
)}
{!loading &&
!error &&
cards.map((card) => (
<VideoConsentCard
key={card.consent.source_video_id}
card={card}
onToggle={handleToggle}
onToggleHistory={toggleHistory}
/>
))}
</div>
</div>
);
}
// ── 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 (
<div className={styles.videoRow}>
<h3 className={styles.videoTitle}>{consent.video_filename}</h3>
<div className={styles.toggleGroup}>
{CONSENT_FIELDS.map(({ key, label }) => (
<div className={styles.toggleRow} key={key}>
<ToggleSwitch
checked={consent[key]}
onChange={(val) => onToggle(videoId, key, val)}
label={label}
disabled={updating === key}
/>
</div>
))}
</div>
{updateError && <p className={styles.updateError}>{updateError}</p>}
<button
className={styles.historyToggle}
onClick={() => onToggleHistory(videoId)}
aria-expanded={historyOpen}
>
<span
className={`${styles.historyArrow} ${historyOpen ? styles.historyArrowOpen : ""}`}
>
</span>
{historyOpen ? "Hide history" : "Show history"}
</button>
{historyOpen && (
<>
{historyLoading && (
<p className={styles.loading} style={{ padding: "0.5rem 0" }}>
Loading history
</p>
)}
{!historyLoading && history.length === 0 && (
<p
style={{
fontSize: "0.8125rem",
color: "var(--color-text-secondary)",
marginTop: "0.5rem",
}}
>
No changes recorded yet.
</p>
)}
{!historyLoading && history.length > 0 && (
<ul className={styles.historyList}>
{history.map((entry, i) => (
<li key={i} className={styles.historyEntry}>
<span className={styles.historyField}>
{entry.field_name}
</span>
<span>
{entry.old_value === null
? "set to"
: entry.old_value
? "on → off"
: "off → on"}
</span>
<span>by {entry.changed_by}</span>
<span className={styles.historyDate}>
{new Date(entry.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)}
</>
)}
</div>
);
}

View file

@ -35,6 +35,13 @@ function SidebarNav() {
</svg>
Content
</span>
<NavLink to="/creator/consent" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Consent
</NavLink>
<NavLink to="/creator/settings" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />

View file

@ -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"}
{"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"}