- "frontend/src/pages/ConsentDashboard.tsx" - "frontend/src/pages/ConsentDashboard.module.css" - "frontend/src/pages/CreatorDashboard.tsx" - "frontend/src/App.tsx" GSD-Task: S03/T02
329 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|