chrysopedia/frontend/src/pages/ConsentDashboard.tsx
jlightner 4115c8add0 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
2026-04-04 00:24:17 +00:00

329 lines
10 KiB
TypeScript

/**
* 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>
);
}