Fix CBT history react-markdown crash, fix test deletion cascade, fix cbt theme, add 180 NEET questions
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user