feat: theme-grouped pickers, theme-session content panel, theme/topic naming
- 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>
This commit is contained in:
@@ -50,7 +50,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
|
||||
<div className="hidden md:flex items-center gap-6 text-sm font-medium">
|
||||
<Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link>
|
||||
<Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Learn</Link>
|
||||
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Test</Link>
|
||||
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Theme Test</Link>
|
||||
<Link to="/topic-test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Target size={16}/> Topic Test</Link>
|
||||
<Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
|
||||
{state.currentUser.role === 'admin' && (
|
||||
@@ -78,7 +78,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-paper border-t border-bg-warm flex justify-around items-center p-2 z-50">
|
||||
<Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link>
|
||||
<Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Learn</span></Link>
|
||||
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Test</span></Link>
|
||||
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Theme</span></Link>
|
||||
<Link to="/topic-test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Target size={20}/><span className="text-[10px] mt-1 font-medium">Topic</span></Link>
|
||||
<Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link>
|
||||
{state.currentUser.role === 'admin' && (
|
||||
|
||||
@@ -1,214 +1,238 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
RefreshCw, Wand2, Trash2, CheckCircle, Loader,
|
||||
AlertCircle, BookOpen, ArrowLeft, Eye
|
||||
Trash2, Loader, AlertCircle, BookOpen, ArrowLeft, Eye, Clock, Layers,
|
||||
} from 'lucide-react';
|
||||
import { getAllGeneratedContent, generateLearningContent, refineLearningContent, deleteCachedContent } from '../../lib/learningService';
|
||||
import LearningContentViewer from '../ui/LearningContentViewer';
|
||||
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 [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState(null); // { topic, content } — the open detail view
|
||||
const [actionState, setActionState] = useState({}); // { [topicId]: { loading, error, success } }
|
||||
const [refineText, setRefineText] = useState('');
|
||||
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 () => {
|
||||
const fresh = await getAllGeneratedContent();
|
||||
setItems(fresh);
|
||||
// Keep detail view in sync if its topic was updated
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fresh = await db.getAllThemeSessions();
|
||||
setSessions(fresh);
|
||||
if (selected) {
|
||||
const updated = fresh.find(i => i.topic.id === selected.topic.id);
|
||||
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 setTopicState = (id, patch) =>
|
||||
setActionState(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));
|
||||
|
||||
const handleRegenerate = async (topic) => {
|
||||
setTopicState(topic.id, { loading: 'regenerating', error: null, success: null });
|
||||
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 generateLearningContent(topic, true, 'all');
|
||||
setTopicState(topic.id, { loading: null, success: 'Content regenerated.' });
|
||||
refresh();
|
||||
await db.deleteThemeSession(id);
|
||||
if (selected?.id === id) setSelected(null);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setTopicState(topic.id, { loading: null, error: e.message });
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefine = async (topic) => {
|
||||
const instruction = refineText.trim();
|
||||
if (!instruction) return;
|
||||
setTopicState(topic.id, { loading: 'refining', error: null, success: null });
|
||||
try {
|
||||
await refineLearningContent(topic, instruction);
|
||||
setTopicState(topic.id, { loading: null, success: 'Content refined.' });
|
||||
setRefineText('');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setTopicState(topic.id, { loading: null, error: e.message });
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (topicId) => {
|
||||
await deleteCachedContent(topicId);
|
||||
if (selected?.topic.id === topicId) setSelected(null);
|
||||
setActionState(prev => { const n = { ...prev }; delete n[topicId]; return n; });
|
||||
refresh();
|
||||
};
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────
|
||||
if (items.length === 0) {
|
||||
// ─── 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 generated content yet.</p>
|
||||
<p className="text-sm mt-1">Upload sources then visit the Learn page to generate content.</p>
|
||||
<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 { topic, content } = selected;
|
||||
const state = actionState[topic.id] || {};
|
||||
const isLoading = !!state.loading;
|
||||
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">
|
||||
{/* Back + header */}
|
||||
<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 content
|
||||
<ArrowLeft size={16} /> Back to all theme sessions
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-2xl font-bold">{topic.label}</h2>
|
||||
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
|
||||
<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>
|
||||
<p className="text-fg-muted text-sm mt-1">{topic.description}</p>
|
||||
{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>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRegenerate(topic)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{state.loading === 'regenerating'
|
||||
? <Loader size={16} className="mr-2 animate-spin" />
|
||||
: <RefreshCw size={16} className="mr-2" />}
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
disabled={isLoading}
|
||||
onClick={() => handleDelete(selected.id)}
|
||||
disabled={busyId === selected.id}
|
||||
className="border-red-200 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" /> Delete
|
||||
{busyId === selected.id
|
||||
? <Loader size={16} className="mr-2 animate-spin" />
|
||||
: <Trash2 size={16} className="mr-2" />}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{(state.success || state.error) && (
|
||||
<div className={`mb-6 p-3 rounded-[var(--r-sm)] text-sm flex items-center gap-2 ${state.error ? 'bg-red-50 text-red-800' : 'bg-teal-50 text-teal-800'}`}>
|
||||
{state.error ? <AlertCircle size={16} /> : <CheckCircle size={16} />}
|
||||
{state.error || state.success}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Full content preview */}
|
||||
<LearningContentViewer content={content} topic={topic} />
|
||||
|
||||
{/* Refine panel */}
|
||||
<Card className="border border-teal/30 mt-8">
|
||||
<h3 className="font-bold text-lg mb-1 flex items-center gap-2">
|
||||
<Wand2 size={18} className="text-teal" /> Refine with AI
|
||||
</h3>
|
||||
<p className="text-sm text-fg-muted mb-4">
|
||||
Review the content above, then describe what should change. Be specific — the AI will apply your instruction and update all content formats.
|
||||
</p>
|
||||
<textarea
|
||||
value={refineText}
|
||||
onChange={(e) => setRefineText(e.target.value)}
|
||||
placeholder='e.g. "Make the article more beginner-friendly and add a real-world example to each slide."'
|
||||
rows={4}
|
||||
className="w-full p-3 rounded-[var(--r-sm)] border border-bg-warm bg-bg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal/30"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button
|
||||
onClick={() => handleRefine(topic)}
|
||||
disabled={isLoading || !refineText.trim()}
|
||||
>
|
||||
{state.loading === 'refining'
|
||||
? <><Loader size={16} className="mr-2 animate-spin" /> Refining...</>
|
||||
: <><Wand2 size={16} className="mr-2" /> Apply Refinement</>}
|
||||
</Button>
|
||||
</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 ────────────────────────────────────────────
|
||||
// ─── List view ───────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map(({ topic, content }) => {
|
||||
const state = actionState[topic.id] || {};
|
||||
const isLoading = !!state.loading;
|
||||
{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={topic.id} className="border border-bg-warm">
|
||||
<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">{topic.label}</span>
|
||||
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span>
|
||||
<Tag variant="success" className="text-xs">Ready</Tag>
|
||||
<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>
|
||||
<p className="text-sm text-fg-muted mt-0.5 truncate">{topic.description}</p>
|
||||
{(state.success || state.error) && (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${state.error ? 'text-red-600' : 'text-teal-700'}`}>
|
||||
{state.error ? <AlertCircle size={12} /> : <CheckCircle size={12} />}
|
||||
{state.error || state.success}
|
||||
{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">
|
||||
{/* Quick actions */}
|
||||
<button
|
||||
onClick={() => handleRegenerate(topic)}
|
||||
disabled={isLoading}
|
||||
title="Regenerate from scratch"
|
||||
className="p-2 rounded-[var(--r-sm)] hover:bg-bg-warm transition-colors text-fg-muted hover:text-teal disabled:opacity-40"
|
||||
>
|
||||
{state.loading === 'regenerating' ? <Loader size={17} className="animate-spin" /> : <RefreshCw size={17} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
disabled={isLoading}
|
||||
title="Delete cached content"
|
||||
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"
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
{isBusy ? <Loader size={17} className="animate-spin" /> : <Trash2 size={17} />}
|
||||
</button>
|
||||
|
||||
{/* Review button */}
|
||||
<Button
|
||||
onClick={() => setSelected({ topic, content })}
|
||||
onClick={() => setSelected(s)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye size={16} /> Review
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye } from 'lucide-react';
|
||||
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, Layers, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService';
|
||||
import * as db from '../../lib/db';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Tag from '../ui/Tag';
|
||||
|
||||
const NO_THEME_KEY = '__no_theme__';
|
||||
const NO_THEME_LABEL = 'No theme';
|
||||
|
||||
// Pure helper: bucket topics by their `theme` field. Topics without a theme
|
||||
// land in a single "No theme" group. Returns an array of { theme, topics }
|
||||
// objects sorted by theme name (No theme last) so the rendering order is
|
||||
// stable across reloads.
|
||||
function groupTopicsByTheme(topics) {
|
||||
const buckets = new Map();
|
||||
for (const t of topics) {
|
||||
const key = t.theme ? t.theme : NO_THEME_KEY;
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(t);
|
||||
}
|
||||
const named = [];
|
||||
let untheme = null;
|
||||
for (const [key, list] of buckets.entries()) {
|
||||
list.sort((a, b) => (a.label || '').localeCompare(b.label || ''));
|
||||
if (key === NO_THEME_KEY) untheme = { theme: NO_THEME_LABEL, topics: list, key };
|
||||
else named.push({ theme: key, topics: list, key });
|
||||
}
|
||||
named.sort((a, b) => a.theme.localeCompare(b.theme));
|
||||
return untheme ? [...named, untheme] : named;
|
||||
}
|
||||
|
||||
const TestManager = () => {
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [questionCounts, setQuestionCounts] = useState({});
|
||||
@@ -13,6 +38,7 @@ const TestManager = () => {
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [loadingTopicId, setLoadingTopicId] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [collapsed, setCollapsed] = useState({}); // { [themeKey]: true } when collapsed
|
||||
|
||||
const loadData = async () => {
|
||||
const allTopics = await db.getTopics();
|
||||
@@ -148,8 +174,10 @@ const TestManager = () => {
|
||||
}
|
||||
|
||||
// ── List view ──
|
||||
const groups = groupTopicsByTheme(topics);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-6">
|
||||
{topics.length === 0 && (
|
||||
<div className="text-center py-16 text-fg-muted">
|
||||
<HelpCircle size={48} className="mx-auto mb-4 text-teal/30" />
|
||||
@@ -163,15 +191,38 @@ const TestManager = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topics.map(topic => {
|
||||
{groups.map(group => {
|
||||
const isCollapsed = !!collapsed[group.key];
|
||||
const totalQuestions = group.topics.reduce((sum, t) => sum + (questionCounts[t.id] || 0), 0);
|
||||
const topicsWithBank = group.topics.filter(t => (questionCounts[t.id] || 0) > 0).length;
|
||||
|
||||
return (
|
||||
<section key={group.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(p => ({ ...p, [group.key]: !p[group.key] }))}
|
||||
className="w-full flex items-center gap-2 mb-3 group"
|
||||
>
|
||||
{isCollapsed
|
||||
? <ChevronRight size={18} className="text-fg-muted group-hover:text-teal transition-colors" />
|
||||
: <ChevronDown size={18} className="text-fg-muted group-hover:text-teal transition-colors" />}
|
||||
<Layers size={16} className="text-teal/70" />
|
||||
<h3 className="text-lg font-bold text-fg group-hover:text-teal transition-colors">{group.theme}</h3>
|
||||
<span className="font-mono text-[11px] text-fg-muted ml-1">
|
||||
{topicsWithBank}/{group.topics.length} topics ready · {totalQuestions} questions
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-2 pl-1">
|
||||
{group.topics.map(topic => {
|
||||
const count = questionCounts[topic.id] || 0;
|
||||
const isLoading = loadingTopicId === topic.id;
|
||||
|
||||
return (
|
||||
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold truncate">{topic.label}</h3>
|
||||
<h4 className="font-semibold truncate">{topic.label}</h4>
|
||||
<Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs">
|
||||
{count} {count === 1 ? 'question' : 'questions'}
|
||||
</Tag>
|
||||
@@ -200,6 +251,11 @@ const TestManager = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -362,6 +362,24 @@ export async function setThemeSession(curriculumVersionId, weekNumber, theme, co
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every cached theme session, newest first. Used by the admin
|
||||
* Content panel to surface what's been generated across all weeks.
|
||||
*/
|
||||
export async function getAllThemeSessions() {
|
||||
try {
|
||||
return await pb.collection('theme_sessions').getFullList({ sort: '-updated' });
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single theme session record by PocketBase id.
|
||||
*/
|
||||
export async function deleteThemeSession(id) {
|
||||
try { return await pb.collection('theme_sessions').delete(id); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the user completed the theme session for a given personal week?
|
||||
* Returns the completion record, or null.
|
||||
|
||||
@@ -87,8 +87,8 @@ const Admin = () => {
|
||||
|
||||
const navItems = [
|
||||
{ key: 'sources', icon: Database, label: 'Sources' },
|
||||
{ key: 'content', icon: Layers, label: 'Content' },
|
||||
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
|
||||
{ key: 'content', icon: Layers, label: 'Theme Content' },
|
||||
{ key: 'tests', icon: CheckSquare, label: 'Topic Quizzes' },
|
||||
{ key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
|
||||
{ key: 'graph', icon: Network, label: 'Graph' },
|
||||
{ key: 'team', icon: Users, label: 'Team' },
|
||||
@@ -162,16 +162,16 @@ const Admin = () => {
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Learning Content</h1>
|
||||
<p className="text-fg-muted mb-8">Manage, refine, or regenerate AI-generated learning modules for each knowledge topic.</p>
|
||||
<h1 className="text-3xl text-teal mb-2">Theme Content</h1>
|
||||
<p className="text-fg-muted mb-8">Weekly theme sessions generated by the AI for the active curriculum. Each session covers all topics in that week's theme.</p>
|
||||
<ContentManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tests' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Question Banks</h1>
|
||||
<p className="text-fg-muted mb-8">Pre-generate and review quiz questions for each topic to eliminate loading times for users.</p>
|
||||
<h1 className="text-3xl text-teal mb-2">Topic Question Banks</h1>
|
||||
<p className="text-fg-muted mb-8">Pre-generate and review per-topic quiz questions, grouped by theme. These power both the weekly theme test and the on-demand Topic Test.</p>
|
||||
<TestManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -111,8 +111,8 @@ const Dashboard = () => {
|
||||
};
|
||||
|
||||
const explainerSteps = [
|
||||
{ icon: BookOpen, title: 'Learn', text: 'Each week you get one topic from the knowledge base. Work through the learning session at your own pace.' },
|
||||
{ icon: CheckSquare, title: 'Test', text: 'Once your learning session is done, take the 5-question test to lock in what you learned and earn points.' },
|
||||
{ icon: BookOpen, title: 'Learn', text: 'Each week the curriculum gives you a theme — a small set of related topics from the knowledge base. Work through the theme session at your own pace.' },
|
||||
{ icon: CheckSquare, title: 'Theme Test', text: 'When your theme session is done, take the 5-question theme test to lock in what you learned and earn points. You can also take an on-demand Topic Test anytime to drill a single topic.' },
|
||||
{ icon: Trophy, title: 'Climb', text: 'Points add up on the leaderboard. The curriculum runs in perpetual 26-week cycles, so there is always a next step.' },
|
||||
{ icon: Sparkles, title: 'Ask R42', text: 'R42, your AI study buddy, is on every screen (bottom-right). Ask it anything about your topic or the platform.' },
|
||||
];
|
||||
@@ -159,7 +159,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
<p className="text-fg-muted text-sm">
|
||||
A perpetual learning loop that keeps you current with the company knowledge base.
|
||||
Use the navigation at the top (Home, Learn, Test, Leaderboard) to move around.
|
||||
Use the navigation at the top (Home, Learn, Theme Test, Topic Test, Leaderboard) to move around.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,21 +255,21 @@ const Dashboard = () => {
|
||||
<Card className="flex flex-col border border-bg-warm" hoverable>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl">Testing</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">Weekly test — 5 questions</p>
|
||||
<h3 className="text-xl">Theme Test</h3>
|
||||
<p className="text-fg-muted text-sm mt-1">This week's theme — 5 questions</p>
|
||||
</div>
|
||||
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle">
|
||||
{!learnDone ? 'Complete your learning session first' : testResult ? 'Test completed for this week' : 'Ready to test your knowledge'}
|
||||
{!learnDone ? 'Complete your theme session first' : testResult ? 'Theme test completed for this week' : 'Ready to test your knowledge'}
|
||||
</div>
|
||||
{learnDone && !testResult ? (
|
||||
<Link to="/test" className="mt-auto">
|
||||
<Button className="w-full">Start Test</Button>
|
||||
<Button className="w-full">Start Theme Test</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/test" className="mt-auto">
|
||||
<Button variant="outline" className="w-full">{testResult ? 'View Results' : 'Start Test'}</Button>
|
||||
<Button variant="outline" className="w-full">{testResult ? 'View Results' : 'Start Theme Test'}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -180,10 +180,10 @@ const Testen = () => {
|
||||
<div className="p-4 md:p-8 max-w-2xl mx-auto">
|
||||
<div className="text-center py-12">
|
||||
<CheckSquare size={64} className="mx-auto text-teal/30 mb-6" />
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Theme Test</h1>
|
||||
<p className="text-fg-muted text-lg mb-2">Cycle {getCurriculumCycle(weekNumber)} · Week {getCurriculumWeek(weekNumber)} of 26</p>
|
||||
<p className="text-fg-muted mb-8 max-w-md mx-auto">
|
||||
Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
|
||||
Test your knowledge of this week's theme with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Loader, AlertCircle, Trophy, ArrowRight, ArrowLeft,
|
||||
Clock, CheckCircle, XCircle, BookOpen,
|
||||
Clock, CheckCircle, XCircle, BookOpen, Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Pure helper: bucket the eligible topics by their `theme` field. Topics with
|
||||
// no theme land in a single "No theme" group, rendered last. Each bucket is
|
||||
// alphabetised by topic label for stable display.
|
||||
function groupEligibleByTheme(topics) {
|
||||
const buckets = new Map();
|
||||
for (const t of topics) {
|
||||
const key = t.theme || '__no_theme__';
|
||||
if (!buckets.has(key)) buckets.set(key, { theme: t.theme || 'No theme', topics: [] });
|
||||
buckets.get(key).topics.push(t);
|
||||
}
|
||||
const out = [];
|
||||
let noTheme = null;
|
||||
for (const [key, val] of buckets.entries()) {
|
||||
val.topics.sort((a, b) => (a.label || '').localeCompare(b.label || ''));
|
||||
if (key === '__no_theme__') noTheme = val;
|
||||
else out.push(val);
|
||||
}
|
||||
out.sort((a, b) => a.theme.localeCompare(b.theme));
|
||||
return noTheme ? [...out, noTheme] : out;
|
||||
}
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
@@ -180,13 +201,22 @@ const TopicTest = () => {
|
||||
<p className="text-sm">An admin needs to add at least {ON_DEMAND_TEST_SIZE} questions to a topic's bank before you can test on it.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{groupEligibleByTheme(topics).map(group => (
|
||||
<section key={group.theme}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Layers size={16} className="text-teal/70" />
|
||||
<h3 className="text-lg font-bold text-fg">{group.theme}</h3>
|
||||
<span className="font-mono text-[11px] text-fg-muted ml-1">
|
||||
{group.topics.length} {group.topics.length === 1 ? 'topic' : 'topics'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{topics.map(topic => (
|
||||
{group.topics.map(topic => (
|
||||
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="font-semibold truncate">{topic.label}</h3>
|
||||
{topic.theme && <Tag variant="accent" className="text-xs">{topic.theme}</Tag>}
|
||||
<h4 className="font-semibold truncate">{topic.label}</h4>
|
||||
<Tag variant="success" className="text-xs">{topic.bankSize} questions</Tag>
|
||||
</div>
|
||||
{topic.description && (
|
||||
@@ -199,6 +229,9 @@ const TopicTest = () => {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user