- Admin Topic Quizzes (was Quizzes): topics are now grouped under collapsible theme headers with per-theme counts. Topics without a theme fall into a "No theme" bucket rendered last. - /topic-test picker: eligible topics grouped by theme so learners can scan a curriculum theme and drill its topics on demand. - Fix admin Theme Content panel (was Learning Content): it was empty because content moved to the theme_sessions collection in an earlier refactor while the panel still queried the legacy per-topic content table. Rewrites ContentManager to read db.getAllThemeSessions and render the emit_theme_session schema (title, intro, topic sections, connections, key takeaways). Adds db.getAllThemeSessions and db.deleteThemeSession. - Disambiguate theme vs topic across the app: the weekly curriculum test is now "Theme Test" (nav, page heading, Dashboard card, button, explainer); the on-demand bank test stays "Topic Test". Admin nav also clarified to "Theme Content" / "Topic Quizzes". No schema changes. 85/85 tests still pass, build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
9.6 KiB
JavaScript
250 lines
9.6 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
Trash2, Loader, AlertCircle, BookOpen, ArrowLeft, Eye, Clock, Layers,
|
|
} from 'lucide-react';
|
|
import * as db from '../../lib/db';
|
|
import Card from '../ui/Card';
|
|
import Button from '../ui/Button';
|
|
import Tag from '../ui/Tag';
|
|
|
|
// Admin "Learning content" panel.
|
|
//
|
|
// The Learn flow stores generated content in the `theme_sessions` PB collection
|
|
// — one record per (curriculum_version, week_number, theme). The legacy
|
|
// per-topic `content` collection is no longer fed by the user-facing flow, so
|
|
// this panel surfaces theme sessions instead. View shows the full session,
|
|
// Delete removes the record (next visitor to that week will regenerate it).
|
|
const ContentManager = () => {
|
|
const [sessions, setSessions] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selected, setSelected] = useState(null);
|
|
const [busyId, setBusyId] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
|
|
const refresh = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const fresh = await db.getAllThemeSessions();
|
|
setSessions(fresh);
|
|
if (selected) {
|
|
const updated = fresh.find(s => s.id === selected.id);
|
|
if (updated) setSelected(updated);
|
|
else setSelected(null);
|
|
}
|
|
} catch (e) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { refresh(); }, []);
|
|
|
|
const handleDelete = async (id) => {
|
|
if (!confirm('Delete this theme session? The next learner to open this week will regenerate it with the AI.')) return;
|
|
setBusyId(id);
|
|
setError(null);
|
|
try {
|
|
await db.deleteThemeSession(id);
|
|
if (selected?.id === id) setSelected(null);
|
|
await refresh();
|
|
} catch (e) {
|
|
setError(e.message);
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
// ─── Loading ──────────────────────────────────────────────
|
|
if (loading && sessions.length === 0) {
|
|
return (
|
|
<div className="text-center py-16 text-fg-muted">
|
|
<Loader size={36} className="mx-auto mb-3 animate-spin text-teal" />
|
|
<p className="text-sm">Loading theme sessions…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Empty ────────────────────────────────────────────────
|
|
if (!loading && sessions.length === 0) {
|
|
return (
|
|
<div className="text-center py-16 text-fg-muted">
|
|
<BookOpen size={48} className="mx-auto mb-4 text-teal/30" />
|
|
<p className="font-medium">No theme sessions generated yet.</p>
|
|
<p className="text-sm mt-1">
|
|
When a learner opens the Learn page for a week with an active curriculum,
|
|
its theme session is generated and cached here.
|
|
</p>
|
|
{error && (
|
|
<p className="text-sm text-red-600 mt-3 flex items-center gap-1 justify-center">
|
|
<AlertCircle size={14} /> {error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Detail view ──────────────────────────────────────────
|
|
if (selected) {
|
|
const content = selected.content || {};
|
|
const topicSections = Array.isArray(content.topicSections) ? content.topicSections : [];
|
|
const takeaways = Array.isArray(content.keyTakeaways) ? content.keyTakeaways : [];
|
|
|
|
return (
|
|
<div className="animate-in fade-in duration-200">
|
|
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
|
|
<div>
|
|
<button
|
|
onClick={() => setSelected(null)}
|
|
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
|
|
>
|
|
<ArrowLeft size={16} /> Back to all theme sessions
|
|
</button>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h2 className="text-2xl font-bold">{content.title || selected.theme || 'Untitled theme session'}</h2>
|
|
<Tag variant="accent" className="text-xs">Week {selected.week_number}</Tag>
|
|
{selected.theme && <Tag variant="dark" className="text-xs">{selected.theme}</Tag>}
|
|
</div>
|
|
{selected.updated && (
|
|
<p className="text-fg-muted text-xs mt-1 flex items-center gap-1">
|
|
<Clock size={12} /> Updated {new Date(selected.updated).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => handleDelete(selected.id)}
|
|
disabled={busyId === selected.id}
|
|
className="border-red-200 text-red-600 hover:bg-red-50"
|
|
>
|
|
{busyId === selected.id
|
|
? <Loader size={16} className="mr-2 animate-spin" />
|
|
: <Trash2 size={16} className="mr-2" />}
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
|
|
<AlertCircle size={16} /> {error}
|
|
</div>
|
|
)}
|
|
|
|
{content.intro && (
|
|
<Card className="border border-bg-warm mb-6">
|
|
<h3 className="font-bold text-base mb-2">Introduction</h3>
|
|
<p className="text-sm leading-relaxed text-fg whitespace-pre-line">{content.intro}</p>
|
|
</Card>
|
|
)}
|
|
|
|
{topicSections.length > 0 && (
|
|
<div className="space-y-3 mb-6">
|
|
<h3 className="font-bold">Topics covered</h3>
|
|
{topicSections.map((s, i) => (
|
|
<Card key={s.topic_id || i} className="border border-bg-warm">
|
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
<span className="font-semibold">{s.label}</span>
|
|
{s.topic_id && (
|
|
<span className="font-mono text-[10px] bg-bg-warm px-1.5 py-0.5 rounded text-fg-muted">{s.topic_id}</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="text-sm prose prose-sm max-w-none text-fg leading-relaxed"
|
|
dangerouslySetInnerHTML={{ __html: s.summary || '' }}
|
|
/>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{content.connections && (
|
|
<Card className="border border-bg-warm mb-6">
|
|
<h3 className="font-bold text-base mb-2">How these topics connect</h3>
|
|
<div
|
|
className="text-sm prose prose-sm max-w-none text-fg leading-relaxed"
|
|
dangerouslySetInnerHTML={{ __html: content.connections }}
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
{takeaways.length > 0 && (
|
|
<Card className="border border-teal/30 bg-teal/5">
|
|
<h3 className="font-bold text-base mb-3">Key takeaways</h3>
|
|
<ul className="space-y-2">
|
|
{takeaways.map((t, i) => (
|
|
<li key={i} className="flex items-start gap-2 text-sm">
|
|
<span className="text-teal mt-1">●</span>
|
|
<span>{t}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── List view ───────────────────────────────────────────
|
|
return (
|
|
<div className="space-y-3">
|
|
{error && (
|
|
<div className="mb-2 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
|
|
<AlertCircle size={16} /> {error}
|
|
</div>
|
|
)}
|
|
|
|
{sessions.map(s => {
|
|
const c = s.content || {};
|
|
const topicCount = Array.isArray(c.topicSections) ? c.topicSections.length : 0;
|
|
const isBusy = busyId === s.id;
|
|
|
|
return (
|
|
<Card key={s.id} className="border border-bg-warm">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-semibold">{c.title || s.theme || 'Untitled'}</span>
|
|
<Tag variant="accent" className="text-xs">Week {s.week_number}</Tag>
|
|
{s.theme && (
|
|
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted flex items-center gap-1">
|
|
<Layers size={11} /> {s.theme}
|
|
</span>
|
|
)}
|
|
<Tag variant="success" className="text-xs">{topicCount} topics</Tag>
|
|
</div>
|
|
{s.updated && (
|
|
<p className="text-xs text-fg-muted mt-1 flex items-center gap-1">
|
|
<Clock size={11} /> Updated {new Date(s.updated).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<button
|
|
onClick={() => handleDelete(s.id)}
|
|
disabled={isBusy}
|
|
title="Delete cached theme session"
|
|
className="p-2 rounded-[var(--r-sm)] hover:bg-red-50 transition-colors text-fg-muted hover:text-red-500 disabled:opacity-40"
|
|
>
|
|
{isBusy ? <Loader size={17} className="animate-spin" /> : <Trash2 size={17} />}
|
|
</button>
|
|
|
|
<Button
|
|
onClick={() => setSelected(s)}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Eye size={16} /> Review
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ContentManager;
|