Spaces:
Running
Running
| 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 ( | |
| <div className="gate-card p-5 space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <h4 className="font-semibold theme-text">Upload Questions via JSON</h4> | |
| <button onClick={onClose} aria-label="Close" className="theme-muted hover:opacity-80"> | |
| <X size={16}/> | |
| </button> | |
| </div> | |
| <div className="px-3 py-3 rounded-lg border theme-panel-card space-y-3"> | |
| <div> | |
| <label className="label">Upload JSON file directly</label> | |
| <div className="flex flex-col sm:flex-row gap-2"> | |
| <input | |
| ref={uploadFileRef} | |
| type="file" | |
| accept=".json,application/json" | |
| onChange={handleUploadFileChange} | |
| className="input text-sm flex-1" | |
| /> | |
| <button | |
| onClick={submitFile} | |
| disabled={fileLoading || !selectedFile} | |
| className="btn-primary flex items-center justify-center gap-2 text-sm py-2 sm:w-64" | |
| > | |
| {fileLoading ? <Spinner size={14}/> : <Upload size={14}/>} | |
| {fileLoading ? 'Uploading...' : 'Upload Questions File (.json)'} | |
| </button> | |
| </div> | |
| </div> | |
| {fileMessage && ( | |
| <div className="px-3 py-2 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 text-sm"> | |
| {fileMessage} | |
| </div> | |
| )} | |
| {fileError && ( | |
| <div className="px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm"> | |
| {fileError} | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex gap-2"> | |
| <button onClick={() => fileRef.current?.click()} className="btn-ghost flex items-center gap-2 text-sm py-2"> | |
| <Upload size={14}/> Load File into Editor | |
| </button> | |
| <button onClick={loadSample} className="btn-ghost flex items-center gap-2 text-sm py-2"> | |
| <FileJson size={14}/> Load Sample | |
| </button> | |
| <input type="file" accept=".json" ref={fileRef} className="hidden" onChange={loadFile} /> | |
| </div> | |
| <div className="px-3 py-2 rounded-lg border theme-panel-card text-xs theme-muted"> | |
| Format: <span className="font-mono theme-muted">{"{ \"questions\": [ { question_type, question_text, options, correct_answer, marks, negative_marks } ] }"}</span> | |
| <br/> | |
| question_type: <span className="text-sky-400">mcq</span> / <span className="text-amber-400">msq</span> / <span className="text-green-400">nat</span> | |
| {' '} · correct_answer: <span className="text-sky-400">A</span> or <span className="text-amber-400">A,C</span> or <span className="text-green-400">42</span> | |
| </div> | |
| <div> | |
| <label className="label">Paste JSON here</label> | |
| <textarea | |
| className="input resize-none font-mono text-xs" | |
| rows={10} | |
| value={text} | |
| onChange={event => handleChange(event.target.value)} | |
| placeholder={'{\n "questions": [\n {\n "question_type": "mcq",\n "question_text": "Your question here?",\n "options": ["A text", "B text", "C text", "D text"],\n "correct_answer": "B",\n "marks": 1,\n "negative_marks": 0.33\n }\n ]\n}'} | |
| /> | |
| </div> | |
| {error && ( | |
| <div className="px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm"> | |
| {error} | |
| </div> | |
| )} | |
| {preview && !error && ( | |
| <div className="px-3 py-2 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 text-sm"> | |
| Valid JSON - {preview.length} question{preview.length !== 1 ? 's' : ''} ready to upload | |
| <div className="mt-1 space-y-0.5"> | |
| {preview.slice(0, 3).map((question, idx) => ( | |
| <p key={`${question.question_type}-${idx}`} className="text-green-400/70 text-xs truncate"> | |
| {idx + 1}. [{question.question_type?.toUpperCase()}] {question.question_text} | |
| </p> | |
| ))} | |
| {preview.length > 3 && <p className="text-green-400/50 text-xs">...and {preview.length - 3} more</p>} | |
| </div> | |
| </div> | |
| )} | |
| <div className="flex gap-3"> | |
| <button onClick={onClose} className="btn-ghost flex-1">Cancel</button> | |
| <button | |
| onClick={submit} | |
| disabled={loading || !preview || !!error} | |
| className="btn-primary flex-1 flex items-center justify-center gap-2" | |
| > | |
| {loading && <Spinner size={14}/>} | |
| {loading ? 'Uploading...' : `Upload ${preview ? preview.length : 0} Questions`} | |
| </button> | |
| </div> | |
| </div> | |
| ) | |
| } | |