import { useRef, useState } from 'react' import toast from 'react-hot-toast' import FileJson from 'lucide-react/dist/esm/icons/file-json' import Upload from 'lucide-react/dist/esm/icons/upload' import X from 'lucide-react/dist/esm/icons/x' import Spinner from '../shared/Spinner' const QUESTION_TYPES = new Set(['mcq', 'msq', 'nat']) const OPTION_LETTERS = new Set(['A', 'B', 'C', 'D']) const NAT_ANSWER_PATTERN = /^-?\d+(\.\d+)?(\s*[-:]\s*-?\d+(\.\d+)?)?$/ const SAMPLE_JSON = JSON.stringify({ questions: [ { question_type: 'mcq', question_text: 'What is the time complexity of binary search?', options: ['O(n)', 'O(log n)', 'O(n log n)', 'O(1)'], correct_answer: 'B', marks: 1, negative_marks: 0.33, subject: 'Algorithms', }, { question_type: 'nat', question_text: 'How many distinct binary trees can be formed with 3 nodes?', options: [], correct_answer: '5', marks: 2, negative_marks: 0, subject: 'Data Structures', }, ], }, null, 2) const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) const validateStringField = (question, idx, field) => { if (typeof question[field] !== 'string' || !question[field].trim()) { throw new Error(`Question ${idx + 1} ${field} must be a non-empty string`) } return question[field].trim() } const validateNumberField = (question, idx, field) => { if (question[field] === undefined) return undefined if (typeof question[field] !== 'number' || !Number.isFinite(question[field])) { throw new Error(`Question ${idx + 1} ${field} must be a number`) } return question[field] } const validateAnswer = (questionType, answer, idx) => { if (questionType === 'mcq' && !OPTION_LETTERS.has(answer)) { throw new Error(`Question ${idx + 1} MCQ correct_answer must be A, B, C, or D`) } if (questionType === 'msq') { const selected = answer.split(',').map(part => part.trim()).filter(Boolean) if (!selected.length || selected.some(part => !OPTION_LETTERS.has(part))) { throw new Error(`Question ${idx + 1} MSQ correct_answer must use option letters like A,C`) } } if (questionType === 'nat' && !NAT_ANSWER_PATTERN.test(answer)) { throw new Error(`Question ${idx + 1} NAT correct_answer must be a number or range`) } } const validateOptions = (question, questionType, idx) => { const options = question.options ?? [] if (!Array.isArray(options)) { throw new Error(`Question ${idx + 1} options must be an array`) } if (questionType === 'nat') return [] if (options.length !== 4 || options.some(option => typeof option !== 'string' || !option.trim())) { throw new Error(`Question ${idx + 1} options must contain exactly 4 non-empty strings`) } return options.map(option => option.trim()) } const validateQuestion = (question, idx) => { if (!isPlainObject(question)) { throw new Error(`Question ${idx + 1} must be an object`) } const questionType = typeof question.question_type === 'string' ? question.question_type.trim().toLowerCase() : '' if (!QUESTION_TYPES.has(questionType)) { throw new Error(`Question ${idx + 1} question_type must be mcq, msq, or nat`) } const questionText = validateStringField(question, idx, 'question_text') const correctAnswer = validateStringField(question, idx, 'correct_answer').toUpperCase().replace(/\s+/g, '') const options = validateOptions(question, questionType, idx) validateAnswer(questionType, correctAnswer, idx) const marks = validateNumberField(question, idx, 'marks') const negativeMarks = validateNumberField(question, idx, 'negative_marks') return { ...question, question_type: questionType, question_text: questionText, options, correct_answer: correctAnswer, ...(marks === undefined ? {} : { marks }), ...(negativeMarks === undefined ? {} : { negative_marks: questionType === 'mcq' ? negativeMarks : 0 }), } } export function extractQuestions(parsed) { let questions if (Array.isArray(parsed)) { questions = parsed } else if (isPlainObject(parsed) && Array.isArray(parsed.questions)) { questions = parsed.questions } else { throw new Error('JSON root must be an array or an object with a "questions" array') } if (questions.length === 0) { throw new Error('JSON does not contain any questions') } return questions.map(validateQuestion) } export function parseQuestionsJson(value) { try { return extractQuestions(JSON.parse(value)) } catch (err) { if (err instanceof SyntaxError) { throw new Error(`Invalid JSON: ${err.message}`) } throw err } } export function readJsonFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = event => resolve(event.target?.result || '') reader.onerror = () => reject(new Error('Failed to read file')) reader.readAsText(file) }) } const formatServerDetail = (detail) => { if (!detail) return 'Failed to upload questions file' if (typeof detail === 'string') return detail if (Array.isArray(detail)) { return detail.map(item => item?.msg || item?.message || String(item)).join('; ') } if (Array.isArray(detail.errors)) { const lines = detail.errors.slice(0, 5).map(err => { const question = err.question_index ? `Q${err.question_index}` : 'Question' const field = err.field ? ` ${err.field}` : '' return `${question}${field}: ${err.message}` }) if (detail.errors.length > 5) lines.push(`...and ${detail.errors.length - 5} more`) return [detail.message, ...lines].filter(Boolean).join(' | ') } return detail.message || JSON.stringify(detail) } export default function JSONUploadForm({ onAdd, onUploadFile, onClose }) { const [text, setText] = useState('') const [loading, setLoading] = useState(false) const [preview, setPreview] = useState(null) const [error, setError] = useState('') const [selectedFile, setSelectedFile] = useState(null) const [fileLoading, setFileLoading] = useState(false) const [fileMessage, setFileMessage] = useState('') const [fileError, setFileError] = useState('') const fileRef = useRef() const uploadFileRef = useRef() const validate = (value) => { setError('') setPreview(null) if (!value.trim()) return try { setPreview(parseQuestionsJson(value)) } catch (err) { setError(err.message) } } const handleChange = (value) => { setText(value) validate(value) } const loadFile = async (event) => { const file = event.target.files[0] if (!file) return try { const content = await readJsonFile(file) setText(content) validate(content) } catch (err) { const message = err.message || 'Failed to read JSON file' setError(message) toast.error(message) } } const loadSample = () => { setText(SAMPLE_JSON) validate(SAMPLE_JSON) } const handleUploadFileChange = (event) => { const file = event.target.files[0] || null setSelectedFile(file) setFileMessage('') setFileError('') } const submitFile = async () => { if (!selectedFile) { toast.error('Select a JSON file first'); return } if (!selectedFile.name.toLowerCase().endsWith('.json')) { toast.error('Only .json files are accepted'); return } setFileLoading(true) setFileMessage('') setFileError('') try { const result = await onUploadFile(selectedFile) const message = result?.message || `Imported ${result?.imported_count || 0} questions from JSON file` setFileMessage(message) setSelectedFile(null) if (uploadFileRef.current) uploadFileRef.current.value = '' toast.success(message) } catch (err) { const message = formatServerDetail(err.response?.data?.detail) setFileError(message) toast.error(message) } finally { setFileLoading(false) } } const submit = async () => { if (!preview || preview.length === 0) { toast.error('No valid questions to upload'); return } setLoading(true) setError('') try { await onAdd(preview) toast.success(`${preview.length} questions added!`) onClose() } catch (err) { const message = formatServerDetail(err.response?.data?.detail) || 'Failed to add questions' setError(message) toast.error(message) } finally { setLoading(false) } } return (
{idx + 1}. [{question.question_type?.toUpperCase()}] {question.question_text}
))} {preview.length > 3 &&...and {preview.length - 3} more
}