import { useState, useEffect, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import * as examApi from '../api/examApi' import type { Exam, Question, QuestionType, Choice } from '../types' const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [ { value: 'multiple-choice', labelKey: 'editor.multipleChoice' }, { value: 'multiple-select', labelKey: 'editor.multipleSelect' }, { value: 'short-answer', labelKey: 'editor.shortAnswer' }, { value: 'translation', labelKey: 'editor.translation' }, { value: 'math-input', labelKey: 'editor.mathInput' }, { value: 'essay', labelKey: 'editor.essay' }, { value: 'match', labelKey: 'editor.match' }, { value: 'order', labelKey: 'editor.order' }, ] const BLANK_EXAM: Exam = { title: '', subtitle: '', questions: [], } function makeBlankQuestion(type: QuestionType, index: number): Question { const id = `q${index + 1}` const base: Question = { id, type, prompt: '', points: 1 } switch (type) { case 'multiple-choice': return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: 'a' } case 'multiple-select': return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: [] } case 'short-answer': case 'translation': case 'math-input': return { ...base, answer: '', acceptedForms: [], caseSensitive: false } case 'essay': return { ...base, minWords: 0, rubric: [] } case 'match': return { ...base, left: [], right: [], answer: {} } case 'order': return { ...base, items: [], answer: [] } default: return base } } export default function ExamEditor() { const { t } = useTranslation() const { id: paramId } = useParams<{ id: string }>() const navigate = useNavigate() const isNew = paramId === 'new' const examId = isNew ? null : Number(paramId) const [exam, setExam] = useState(BLANK_EXAM) const [selectedIdx, setSelectedIdx] = useState(0) const [loading, setLoading] = useState(!isNew) const [error, setError] = useState(null) const [saving, setSaving] = useState(false) const [saveMsg, setSaveMsg] = useState(null) const [hasChanges, setHasChanges] = useState(isNew) // Load existing exam useEffect(() => { if (isNew) return setLoading(true) examApi.getExam(examId!) .then((data) => { setExam(data) setHasChanges(false) }) .catch((e) => setError((e as Error).message)) .finally(() => setLoading(false)) }, [examId, isNew]) const updateExam = useCallback((fn: (draft: Exam) => void) => { setExam((prev) => { const draft = structuredClone(prev) fn(draft) return draft }) setHasChanges(true) setSaveMsg(null) }, []) const addQuestion = (type: QuestionType) => { updateExam((draft) => { const q = makeBlankQuestion(type, draft.questions.length) draft.questions.push(q) }) setSelectedIdx(exam.questions.length) } const removeQuestion = (idx: number) => { updateExam((draft) => { draft.questions.splice(idx, 1) }) if (selectedIdx >= idx && selectedIdx > 0) { setSelectedIdx(selectedIdx - 1) } } const saveExam = async () => { if (!exam.title.trim()) { setError(t('editor.examTitleRequired')) return } if (exam.questions.length === 0) { setError(t('editor.atLeastOneQuestion')) return } setSaving(true) setError(null) try { if (isNew) { const data = await examApi.createExam(exam) setSaveMsg(t('editor.examCreated')) setHasChanges(false) navigate(`/exam-editor/${data.id}`, { replace: true }) } else { await examApi.updateExam(examId!, exam) setSaveMsg(t('editor.examSaved')) setHasChanges(false) } setTimeout(() => setSaveMsg(null), 3000) } catch (e) { setError((e as Error).message) } finally { setSaving(false) } } const selectedQuestion = exam.questions[selectedIdx] const truncate = (s: string, n = 40) => (s.length > n ? s.slice(0, n) + '…' : s) return (
{/* Header */}
updateExam((d) => { d.title = e.target.value })} placeholder={t('editor.examTitlePlaceholder')} />
{saveMsg && {saveMsg}} {hasChanges && {t('editor.unsavedChanges')}}
{error && (
{error}
)} {loading ? (
{t('common.loading')}
) : (
{/* Sidebar */} {/* Main Editor */}
{selectedQuestion ? ( { updateExam((draft) => { const q = draft.questions[selectedIdx] if (q) fn(q) }) }} /> ) : (
◇
{t('editor.noQuestions')}
{t('editor.noQuestionsDesc')}
)}
)}
) } /* ─── Question Editor ─── */ interface QEProps { question: Question onChange: (fn: (q: Question) => void) => void } function QuestionEditor({ question, onChange }: QEProps) { const { t } = useTranslation() const update = (key: K, value: Question[K]) => { onChange((q) => { q[key] = value }) } return (
{t('editor.questionEditor')} {question.type}