Fix CBT history react-markdown crash, fix test deletion cascade, fix cbt theme, add 180 NEET questions

This commit is contained in:
2026-07-09 23:37:15 +05:30
parent 7c5acbd823
commit 149d365218
11 changed files with 8308 additions and 64 deletions

View File

@@ -6,24 +6,51 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Loader2, Upload, FileText, Clock, CalendarIcon } from "lucide-react"
import { Loader2, Upload, FileText, Clock, CalendarIcon, CheckCircle2, AlertCircle, X, Trash2 } from "lucide-react"
import DatePicker from "react-datepicker"
import "react-datepicker/dist/react-datepicker.css"
import { z } from "zod"
import { useActiveUser } from "@/hooks/useActiveUser"
// Strict Zod schema for client-side validation
const QuestionSchema = z.object({
id: z.string().optional(),
section: z.string().default("General"),
subject: z.string(),
topic: z.string(),
subtopic: z.string(),
difficulty: z.string(),
type: z.enum(["MCQ_Single", "MCQ_Multiple", "Numerical"]),
question_text: z.string(),
options: z.record(z.string(), z.any()).nullable().optional(),
correct_answer: z.object({
value: z.any()
}),
solution_text: z.string(),
language: z.enum(["English", "Hindi", "Bilingual"])
}).passthrough();
const TestSchema = z.array(QuestionSchema);
export default function AdminTestsPage() {
const [tests, setTests] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const { userId } = useActiveUser()
// Upload Form State
const [title, setTitle] = useState("")
const [examPattern, setExamPattern] = useState("JEE_Main")
const [timeLimit, setTimeLimit] = useState("180")
const [scheduledDate, setScheduledDate] = useState<Date | null>(null)
const [endDate, setEndDate] = useState<Date | null>(null)
const [questionsJson, setQuestionsJson] = useState("")
// Drag and Drop State
const [isDragging, setIsDragging] = useState(false)
const [validQuestions, setValidQuestions] = useState<Record<string, unknown>[] | null>(null)
const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const fetchTests = async () => {
try {
@@ -40,6 +67,7 @@ export default function AdminTestsPage() {
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchTests()
}, [])
@@ -56,28 +84,106 @@ export default function AdminTestsPage() {
}
}
const handleDelete = async (testId: string) => {
if (!confirm("Are you sure you want to delete this test and all its questions? This action cannot be undone.")) return
try {
const res = await fetch(`/api/admin/tests/${testId}`, {
method: "DELETE"
})
if (res.ok) {
alert("Test deleted successfully!")
fetchTests()
} else {
alert("Failed to delete test.")
}
} catch (e) {
console.error(e)
alert("Error deleting test")
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
processFile(e.dataTransfer.files[0])
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
processFile(e.target.files[0])
}
}
const processFile = (file: File) => {
if (!file.name.endsWith('.json')) {
setValidationErrors([{ path: "File", message: "Only .json files are allowed." }])
setValidQuestions(null)
return
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const json = JSON.parse(e.target?.result as string)
// Zod validation
const result = TestSchema.safeParse(json)
if (result.success) {
setValidQuestions(result.data)
setValidationErrors([])
} else {
setValidQuestions(null)
const errors = (result as any).error.errors.map((err: any) => ({
path: err.path.join('.'),
message: err.message
}))
setValidationErrors(errors)
}
} catch {
setValidQuestions(null)
setValidationErrors([{ path: "JSON Parsing", message: "Invalid JSON format. Check for syntax errors." }])
}
}
reader.readAsText(file)
}
const removeFile = () => {
setValidQuestions(null)
setValidationErrors([])
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault()
if (!questionsJson) return
if (!validQuestions || validQuestions.length === 0) {
alert("Please upload a valid JSON file with questions first.")
return
}
if (!userId) return
setUploading(true)
let parsedQuestions = [];
try {
parsedQuestions = JSON.parse(questionsJson);
} catch(err) {
alert("Invalid JSON format. Please paste a valid JSON array.");
setUploading(false);
return;
}
const payload = {
title,
exam_pattern: examPattern,
time_limit: parseInt(timeLimit),
admin_id: userId,
questions_json: parsedQuestions,
questions_json: validQuestions,
scheduled_date: scheduledDate ? scheduledDate.toISOString() : null,
end_date: endDate ? endDate.toISOString() : null,
};
@@ -92,10 +198,11 @@ export default function AdminTestsPage() {
if (res.ok) {
alert("Test created successfully!")
setTitle("")
setQuestionsJson("")
removeFile()
fetchTests()
} else {
alert("Creation failed")
const err = await res.json()
alert(`Creation failed: ${JSON.stringify(err)}`)
}
} catch (error) {
console.error(error)
@@ -115,13 +222,13 @@ export default function AdminTestsPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Upload Form */}
<Card className="lg:col-span-1 border-muted bg-card shadow-sm">
<Card className="lg:col-span-1 border-muted bg-card shadow-sm h-fit">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Create Manual Test
</CardTitle>
<CardDescription>Paste the structured JSON block of questions in LaTeX format to create a deterministic test.</CardDescription>
<CardDescription>Upload a structured JSON array of questions in LaTeX format to create a deterministic test.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpload} className="space-y-4">
@@ -150,7 +257,7 @@ export default function AdminTestsPage() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 flex flex-col">
<label className="text-sm font-medium">Scheduled Start (Optional)</label>
<label className="text-sm font-medium">Scheduled Start</label>
<DatePicker
selected={scheduledDate}
onChange={(date: Date | null) => setScheduledDate(date)}
@@ -160,11 +267,11 @@ export default function AdminTestsPage() {
timeCaption="time"
dateFormat="MMMM d, yyyy h:mm aa"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
placeholderText="Select start date & time"
placeholderText="Optional"
/>
</div>
<div className="space-y-2 flex flex-col">
<label className="text-sm font-medium">Scheduled End (Optional)</label>
<label className="text-sm font-medium">Scheduled End</label>
<DatePicker
selected={endDate}
onChange={(date: Date | null) => setEndDate(date)}
@@ -174,24 +281,66 @@ export default function AdminTestsPage() {
timeCaption="time"
dateFormat="MMMM d, yyyy h:mm aa"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
placeholderText="Select end date & time"
placeholderText="Optional"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Questions JSON (LaTeX Format)</label>
<textarea
required
value={questionsJson}
onChange={e => setQuestionsJson(e.target.value)}
className="w-full min-h-[250px] p-3 text-sm font-mono border rounded-md bg-background focus:ring-primary focus:border-primary"
placeholder='[{"section": "Physics", "text_en": "Question text...", "options_en": ["A", "B", "C", "D"], "correct_answer": "A"}]'
/>
<p className="text-xs text-muted-foreground mt-1">Paste your questions as a JSON array.</p>
<div className="space-y-2 pt-2">
<label className="text-sm font-medium">Questions JSON File</label>
{validQuestions ? (
<div className="flex items-center justify-between p-3 border border-green-500/50 bg-green-500/10 rounded-md">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-5 h-5" />
<span className="text-sm font-medium">{validQuestions.length} questions validated</span>
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" onClick={removeFile}>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div
className={`relative flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-md transition-colors ${isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium text-center">Drag and drop your .json file here</p>
<p className="text-xs text-muted-foreground text-center mt-1">or click to browse files</p>
<input
type="file"
accept=".json"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
ref={fileInputRef}
/>
</div>
)}
{validationErrors.length > 0 && (
<div className="mt-2 p-3 border border-destructive/50 bg-destructive/10 rounded-md max-h-48 overflow-y-auto">
<div className="flex items-center gap-2 text-destructive mb-2">
<AlertCircle className="w-4 h-4" />
<span className="text-sm font-bold">Validation Errors Found</span>
</div>
<ul className="space-y-1">
{validationErrors.slice(0, 50).map((err, i) => (
<li key={i} className="text-xs text-destructive/90 flex gap-2">
<span className="font-mono bg-destructive/20 px-1 rounded">{err.path}</span>
<span>{err.message}</span>
</li>
))}
{validationErrors.length > 50 && (
<li className="text-xs text-muted-foreground italic mt-2">...and {validationErrors.length - 50} more errors</li>
)}
</ul>
</div>
)}
</div>
<Button type="submit" className="w-full" disabled={uploading}>
<Button type="submit" className="w-full" disabled={uploading || !validQuestions}>
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
{uploading ? "Creating Test..." : "Create Test from JSON"}
</Button>
@@ -200,7 +349,7 @@ export default function AdminTestsPage() {
</Card>
{/* Existing Tests List */}
<Card className="lg:col-span-2 border-muted bg-card shadow-sm">
<Card className="lg:col-span-2 border-muted bg-card shadow-sm h-fit">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
@@ -240,9 +389,19 @@ export default function AdminTestsPage() {
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(test.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
@@ -250,7 +409,6 @@ export default function AdminTestsPage() {
)}
</CardContent>
</Card>
</div>
</div>
)

View File

@@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Clock, Flag, ChevronLeft, ChevronRight, Pause as PauseIcon, MoreVertical, Calculator as CalculatorIcon, FlaskConical, Edit3, XCircle, Play, Shield, AlertTriangle, Moon, Sun } from "lucide-react"
import Image from "next/image"
import { useTheme } from "next-themes"
import { useCBTStore, QuestionStatus } from "@/store/cbtStore"
import { useExamTimer } from "@/hooks/useExamTimer"
import ReactMarkdown from "react-markdown"
@@ -38,7 +37,29 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
const [showPeriodicTable, setShowPeriodicTable] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false);
const { theme, setTheme } = useTheme();
const [localTheme, setLocalTheme] = useState<string>('light');
useEffect(() => {
if (typeof window !== 'undefined') {
if (document.documentElement.classList.contains('dark')) {
setLocalTheme('dark');
} else {
setLocalTheme('light');
}
}
}, []);
const handleThemeToggle = () => {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
setLocalTheme('light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
setLocalTheme('dark');
}
};
// Anti-cheat state
const [violationCount, setViolationCount] = useState(0);
@@ -63,9 +84,6 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
resetExamState,
status,
attemptId,
currentQuestionId,
getQuestion,
examState
} = useCBTStore();
const { formattedTime, isTimedOut } = useExamTimer();
@@ -243,8 +261,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
<p className="font-semibold">Preferred Theme</p>
<p className="text-sm text-muted-foreground">Choose dark or light mode for the exam</p>
</div>
<Button variant="outline" size="icon" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
<Button variant="outline" size="icon" onClick={handleThemeToggle}>
{localTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
</div>
@@ -267,13 +285,13 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
onRetake={examType === 'DPS' ? () => {
resetExamState();
window.location.reload();
} : undefined}
} : () => {}}
/>
)
}
const currentQuestion = getQuestion(currentQuestionId)
const currentState = currentQuestion ? examState?.answers[currentQuestion.id] : null
const currentQuestion = questions[currentQuestionIndex];
const currentState = currentQuestion ? answers[currentQuestion.id] : null
const handleOptionSelect = (qId: string, letter: string, type: string) => {
if (type === 'Multiple_Choice') {
@@ -281,7 +299,11 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
const selectedArray = currentSelected.split(',').filter(Boolean);
if (selectedArray.includes(letter)) {
const newArray = selectedArray.filter((l: string) => l !== letter).sort();
selectAnswer(qId, newArray.length > 0 ? newArray.join(',') : null);
if (newArray.length > 0) {
selectAnswer(qId, newArray.join(','));
} else {
clearResponse(qId);
}
} else {
selectedArray.push(letter);
selectAnswer(qId, selectedArray.sort().join(','));
@@ -291,7 +313,8 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
}
}
// Effect to close overlay tools on EscapealetteStats = Object.values(answers).reduce(
// Effect to close overlay tools on Escape
const paletteStats = Object.values(answers || {}).reduce(
(acc, val) => {
acc[val.status]++;
return acc;

View File

@@ -118,6 +118,37 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
const q = questions[ans.question_id];
if (!q) return null;
const getUserAnswerText = () => {
if (ans.is_subjective || q.type === 'Subjective' || q.type === 'Numerical') {
if (Array.isArray(ans.selected_answer)) {
return ans.selected_answer.join(", ");
}
return String(ans.selected_answer || 'Not Attempted');
}
if (Array.isArray(ans.selected_answer)) {
return ans.selected_answer.map((val: any) => q.options && q.options[val] ? `**${val})** ${q.options[val]}` : val).join("\n\n");
}
return q.options && q.options[ans.selected_answer]
? `**${ans.selected_answer})** ${q.options[ans.selected_answer]}`
: String(ans.selected_answer || 'Not Attempted');
};
const getCorrectAnswerText = () => {
const rawCorrect = q.correct_answer?.correct || q.correct_answer?.value || q.correct_answer?.selected;
if (ans.is_subjective || q.type === 'Subjective' || q.type === 'Numerical') {
if (Array.isArray(rawCorrect)) {
return rawCorrect.join(", ");
}
return String(rawCorrect || 'Subjective text');
}
if (Array.isArray(rawCorrect)) {
return rawCorrect.map((val: any) => q.options && q.options[val] ? `**${val})** ${q.options[val]}` : val).join("\n\n");
}
return q.options && q.options[rawCorrect]
? `**${rawCorrect})** ${q.options[rawCorrect]}`
: String(rawCorrect || 'N/A');
};
return (
<Card key={ans.question_id} className={`border-l-4 ${ans.is_correct ? 'border-l-green-500' : 'border-l-red-500'}`}>
<CardContent className="p-6">
@@ -150,9 +181,7 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
<p className="text-sm font-bold text-muted-foreground uppercase">Your Answer</p>
<div className={`p-3 rounded-lg border prose prose-sm dark:prose-invert ${ans.is_correct ? 'bg-green-500/10 border-green-500/30 text-green-500' : 'bg-red-500/10 border-red-500/30 text-red-500'}`}>
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{ans.is_subjective || q.type === 'Subjective' || q.type === 'Numerical'
? (ans.selected_answer || 'Not Attempted')
: (q.options && q.options[ans.selected_answer] ? `${ans.selected_answer}) ${q.options[ans.selected_answer]}` : ans.selected_answer || 'Not Attempted')}
{getUserAnswerText()}
</ReactMarkdown>
</div>
</div>
@@ -160,11 +189,7 @@ export default function AttemptHistoryPage({ params }: { params: Promise<{ attem
<p className="text-sm font-bold text-muted-foreground uppercase">Correct Answer</p>
<div className="p-3 rounded-lg border bg-green-500/10 border-green-500/30 text-green-500 prose prose-sm dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{ans.is_subjective || q.type === 'Subjective' || q.type === 'Numerical'
? (q.correct_answer?.value || q.correct_answer?.correct || q.correct_answer?.selected || 'Subjective text')
: (q.options && q.options[q.correct_answer?.correct || q.correct_answer?.value || q.correct_answer?.selected]
? `${q.correct_answer?.correct || q.correct_answer?.value || q.correct_answer?.selected}) ${q.options[q.correct_answer?.correct || q.correct_answer?.value || q.correct_answer?.selected]}`
: (q.correct_answer?.correct || q.correct_answer?.value || q.correct_answer?.selected || 'N/A'))}
{getCorrectAnswerText()}
</ReactMarkdown>
</div>
</div>