208 lines
7.6 KiB
JavaScript
208 lines
7.6 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, ChevronDown, ChevronUp } 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';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
|
|
const TestManager = () => {
|
|
const [topics, setTopics] = useState([]);
|
|
const [questionCounts, setQuestionCounts] = useState({});
|
|
const [selectedTopic, setSelectedTopic] = useState(null);
|
|
const [questions, setQuestions] = useState([]);
|
|
const [loadingTopicId, setLoadingTopicId] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
|
|
const loadData = async () => {
|
|
const allTopics = await db.getTopics();
|
|
setTopics(allTopics);
|
|
|
|
const counts = {};
|
|
await Promise.all(allTopics.map(async t => {
|
|
const bank = await getTopicQuestionBank(t.id);
|
|
counts[t.id] = bank.length;
|
|
}));
|
|
setQuestionCounts(counts);
|
|
|
|
if (selectedTopic) {
|
|
setQuestions(await getTopicQuestionBank(selectedTopic.id));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [selectedTopic]);
|
|
|
|
const handleGenerate = async (topic, count = 10) => {
|
|
setLoadingTopicId(topic.id);
|
|
setError(null);
|
|
try {
|
|
await forceGenerateTopicQuestions(topic, count);
|
|
loadData(); // refresh list
|
|
} catch (e) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoadingTopicId(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = (topicId, questionId) => {
|
|
deleteQuestion(topicId, questionId);
|
|
loadData();
|
|
};
|
|
|
|
// ── Detail view ──
|
|
if (selectedTopic) {
|
|
return (
|
|
<div className="animate-in fade-in duration-200">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
|
|
<div>
|
|
<button
|
|
onClick={() => setSelectedTopic(null)}
|
|
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
|
|
>
|
|
<ArrowLeft size={16} /> Back to Topics
|
|
</button>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h2 className="text-2xl font-bold">{selectedTopic.label}</h2>
|
|
<Tag variant="accent" className="text-xs">{questions.length} questions</Tag>
|
|
</div>
|
|
<p className="text-fg-muted text-sm mt-1">{selectedTopic.description}</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => handleGenerate(selectedTopic, 5)}
|
|
disabled={loadingTopicId === selectedTopic.id}
|
|
>
|
|
{loadingTopicId === selectedTopic.id
|
|
? <Loader size={16} className="mr-2 animate-spin" />
|
|
: <RefreshCw size={16} className="mr-2" />}
|
|
Generate 5 More
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
)}
|
|
|
|
{/* Questions List */}
|
|
<div className="space-y-4">
|
|
{questions.length === 0 ? (
|
|
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
|
|
<p>No questions generated for this topic yet.</p>
|
|
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 10)} disabled={loadingTopicId === selectedTopic.id}>
|
|
Generate 10 Questions
|
|
</Button>
|
|
</Card>
|
|
) : (
|
|
questions.map((q, i) => (
|
|
<Card key={q.id} className="border border-bg-warm relative group">
|
|
<button
|
|
onClick={() => handleDelete(selectedTopic.id, q.id)}
|
|
className="absolute top-4 right-4 p-2 text-fg-muted hover:text-red-500 hover:bg-red-50 rounded-[var(--r-sm)] opacity-0 group-hover:opacity-100 transition-all"
|
|
title="Delete question"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
|
|
<div className="flex items-start gap-3 mb-4">
|
|
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-teal/10 text-teal flex items-center justify-center text-xs font-bold mt-0.5">
|
|
{i + 1}
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold">{q.question}</h4>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 ml-9 mb-4">
|
|
{q.options.map((opt, oi) => (
|
|
<div
|
|
key={oi}
|
|
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
|
|
oi === q.correctIndex
|
|
? 'border-teal bg-teal/5 text-teal font-medium'
|
|
: 'border-bg-warm text-fg-muted'
|
|
}`}
|
|
>
|
|
{oi === q.correctIndex && <CheckCircle size={14} className="inline mr-2 -mt-0.5" />}
|
|
{opt}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-sm text-fg-muted ml-9 italic border-l-2 border-teal/30 pl-3">
|
|
{q.explanation}
|
|
</p>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── List view ──
|
|
return (
|
|
<div className="space-y-3">
|
|
{topics.length === 0 && (
|
|
<div className="text-center py-16 text-fg-muted">
|
|
<HelpCircle size={48} className="mx-auto mb-4 text-teal/30" />
|
|
<p className="font-medium">No topics available.</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && !selectedTopic && (
|
|
<div className="mb-4 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
|
|
<AlertCircle size={16} /> {error}
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
<Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs">
|
|
{count} {count === 1 ? 'question' : 'questions'}
|
|
</Tag>
|
|
</div>
|
|
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{count === 0 ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => handleGenerate(topic)}
|
|
disabled={isLoading}
|
|
className="whitespace-nowrap"
|
|
>
|
|
{isLoading ? <Loader size={16} className="animate-spin mr-2" /> : <RefreshCw size={16} className="mr-2" />}
|
|
Generate
|
|
</Button>
|
|
) : (
|
|
<Button onClick={() => setSelectedTopic(topic)} className="flex items-center gap-2">
|
|
<Eye size={16} /> Review
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TestManager;
|