Public Access
- Integrated i18next for language detection and translation support. - Added English and Dutch JSON translation files for various app sections. - Updated TweaksPanel and Workspace components to use translated strings. - Imported i18n configuration in main entry file.
768 lines
27 KiB
TypeScript
768 lines
27 KiB
TypeScript
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<Exam>(BLANK_EXAM)
|
||
const [selectedIdx, setSelectedIdx] = useState(0)
|
||
const [loading, setLoading] = useState(!isNew)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
const [saveMsg, setSaveMsg] = useState<string | null>(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 (
|
||
<div className="exam-editor-shell">
|
||
{/* Header */}
|
||
<header className="exam-editor-header">
|
||
<div className="exam-editor-header-left">
|
||
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/exams')}>
|
||
{t('editor.back')}
|
||
</button>
|
||
<input
|
||
className="exam-editor-title-input"
|
||
value={exam.title}
|
||
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
|
||
placeholder={t('editor.examTitlePlaceholder')}
|
||
/>
|
||
</div>
|
||
<div className="exam-editor-header-right">
|
||
{saveMsg && <span className="exam-editor-save-msg">{saveMsg}</span>}
|
||
{hasChanges && <span className="exam-editor-unsaved">{t('editor.unsavedChanges')}</span>}
|
||
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
|
||
{saving ? t('editor.saving') : isNew ? t('editor.createExam') : t('editor.save')}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{error && (
|
||
<div className="workspace-alert workspace-alert-error exam-editor-alert">
|
||
<span>{error}</span>
|
||
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="exam-editor-loading">{t('common.loading')}</div>
|
||
) : (
|
||
<div className="exam-editor-body">
|
||
{/* Sidebar */}
|
||
<aside className="exam-editor-sidebar">
|
||
<div className="sidebar-section-label">{t('editor.questionsLabel')}</div>
|
||
<div className="nav-list">
|
||
{exam.questions.map((q, i) => (
|
||
<button
|
||
key={q.id}
|
||
className={`nav-item${selectedIdx === i ? ' active' : ''}`}
|
||
onClick={() => setSelectedIdx(i)}
|
||
>
|
||
<span className="nav-item-num">{i + 1}</span>
|
||
<span className="nav-item-title">{truncate(q.prompt) || 'Untitled'}</span>
|
||
<span
|
||
className="exam-editor-q-remove"
|
||
onClick={(e) => { e.stopPropagation(); removeQuestion(i) }}
|
||
title={t('common.delete')}
|
||
>
|
||
✕
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="exam-editor-add">
|
||
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
|
||
<div className="exam-editor-type-grid">
|
||
{QUESTION_TYPES.map((tType) => (
|
||
<button
|
||
key={tType.value}
|
||
className="exam-editor-type-btn"
|
||
onClick={() => addQuestion(tType.value)}
|
||
>
|
||
{t(tType.labelKey)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Main Editor */}
|
||
<main className="exam-editor-main">
|
||
{selectedQuestion ? (
|
||
<QuestionEditor
|
||
question={selectedQuestion}
|
||
onChange={(fn) => {
|
||
updateExam((draft) => {
|
||
const q = draft.questions[selectedIdx]
|
||
if (q) fn(q)
|
||
})
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="workspace-empty">
|
||
<div className="workspace-empty-icon">◇</div>
|
||
<div className="workspace-empty-title">{t('editor.noQuestions')}</div>
|
||
<div className="workspace-empty-desc">{t('editor.noQuestionsDesc')}</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Question Editor ─── */
|
||
|
||
interface QEProps {
|
||
question: Question
|
||
onChange: (fn: (q: Question) => void) => void
|
||
}
|
||
|
||
function QuestionEditor({ question, onChange }: QEProps) {
|
||
const { t } = useTranslation()
|
||
const update = <K extends keyof Question>(key: K, value: Question[K]) => {
|
||
onChange((q) => { q[key] = value })
|
||
}
|
||
|
||
return (
|
||
<div className="question-form">
|
||
<div className="question-form-header">
|
||
<span className="mono faint" style={{ fontSize: 12 }}>{t('editor.questionEditor')}</span>
|
||
<span className="q-category" style={{ marginLeft: 'auto' }}>{question.type}</span>
|
||
</div>
|
||
|
||
<div className="question-form-row">
|
||
<label className="question-form-label">{t('editor.prompt')}</label>
|
||
<textarea
|
||
className="textarea"
|
||
value={question.prompt}
|
||
onChange={(e) => update('prompt', e.target.value)}
|
||
rows={3}
|
||
placeholder={t('editor.promptPlaceholder')}
|
||
/>
|
||
</div>
|
||
|
||
<div className="question-form-row-inline">
|
||
<div>
|
||
<label className="question-form-label">{t('editor.categoryOptional')}</label>
|
||
<input
|
||
className="input"
|
||
value={question.category ?? ''}
|
||
onChange={(e) => update('category', e.target.value || undefined)}
|
||
placeholder={t('editor.categoryPlaceholder')}
|
||
/>
|
||
</div>
|
||
<div style={{ width: 100 }}>
|
||
<label className="question-form-label">{t('editor.points')}</label>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={1}
|
||
value={question.points ?? 1}
|
||
onChange={(e) => update('points', Math.max(1, Number(e.target.value)))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="question-form-row">
|
||
<label className="question-form-label">{t('editor.explanationOptional')}</label>
|
||
<textarea
|
||
className="textarea"
|
||
value={question.explanation ?? ''}
|
||
onChange={(e) => update('explanation', e.target.value || undefined)}
|
||
rows={2}
|
||
placeholder={t('editor.explanationPlaceholder')}
|
||
/>
|
||
</div>
|
||
|
||
{/* Type-specific editors */}
|
||
{question.type === 'multiple-choice' && <MultipleChoiceEditor q={question} onChange={onChange} />}
|
||
{question.type === 'multiple-select' && <MultipleSelectEditor q={question} onChange={onChange} />}
|
||
{(question.type === 'short-answer' || question.type === 'translation' || question.type === 'math-input') && (
|
||
<TextAnswerEditor q={question} onChange={onChange} />
|
||
)}
|
||
{question.type === 'translation' && (
|
||
<div className="question-form-row-inline">
|
||
<div>
|
||
<label className="question-form-label">{t('editor.sourceLanguage')}</label>
|
||
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder={t('editor.sourceLanguagePlaceholder')} />
|
||
</div>
|
||
<div>
|
||
<label className="question-form-label">{t('editor.targetLanguage')}</label>
|
||
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder={t('editor.targetLanguagePlaceholder')} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
|
||
{question.type === 'match' && <MatchEditor q={question} onChange={onChange} />}
|
||
{question.type === 'order' && <OrderEditor q={question} onChange={onChange} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Multiple Choice ─── */
|
||
|
||
function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||
const { t } = useTranslation()
|
||
const choices = q.choices ?? []
|
||
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
|
||
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
|
||
|
||
const addChoice = () => {
|
||
const nextId = String.fromCharCode(97 + choices.length) // a, b, c...
|
||
setChoices([...choices, { id: nextId, text: '' }])
|
||
}
|
||
|
||
const removeChoice = (idx: number) => {
|
||
const next = choices.filter((_, i) => i !== idx)
|
||
// Renumber
|
||
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
|
||
setChoices(renumbered)
|
||
if (q.answer === choices[idx]?.id) setAnswer(renumbered[0]?.id ?? '')
|
||
}
|
||
|
||
const updateChoice = (idx: number, text: string) => {
|
||
const next = [...choices]
|
||
next[idx] = { ...next[idx], text }
|
||
setChoices(next)
|
||
}
|
||
|
||
return (
|
||
<div className="question-form-section">
|
||
<label className="question-form-label">{t('editor.choices')}</label>
|
||
<div className="choice-editor-list">
|
||
{choices.map((c, i) => (
|
||
<div key={c.id} className="choice-editor-row">
|
||
<input
|
||
type="radio"
|
||
name="mc-answer"
|
||
checked={q.answer === c.id}
|
||
onChange={() => setAnswer(c.id)}
|
||
title={t('editor.markAsCorrect')}
|
||
/>
|
||
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
|
||
<input
|
||
className="input"
|
||
value={c.text}
|
||
onChange={(e) => updateChoice(i, e.target.value)}
|
||
placeholder={t('editor.choicePlaceholder', { id: c.id })}
|
||
/>
|
||
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
|
||
{t('editor.addChoice')}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Multiple Select ─── */
|
||
|
||
function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||
const { t } = useTranslation()
|
||
const choices = q.choices ?? []
|
||
const answer = (q.answer as string[]) ?? []
|
||
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
|
||
const toggleAnswer = (id: string) => {
|
||
const next = answer.includes(id) ? answer.filter((a) => a !== id) : [...answer, id]
|
||
onChange((q) => { q.answer = next })
|
||
}
|
||
|
||
const addChoice = () => {
|
||
const nextId = String.fromCharCode(97 + choices.length)
|
||
setChoices([...choices, { id: nextId, text: '' }])
|
||
}
|
||
|
||
const removeChoice = (idx: number) => {
|
||
const removedId = choices[idx].id
|
||
const next = choices.filter((_, i) => i !== idx)
|
||
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
|
||
setChoices(renumbered)
|
||
onChange((q) => { q.answer = (q.answer as string[]).filter((a) => a !== removedId) })
|
||
}
|
||
|
||
const updateChoice = (idx: number, text: string) => {
|
||
const next = [...choices]
|
||
next[idx] = { ...next[idx], text }
|
||
setChoices(next)
|
||
}
|
||
|
||
return (
|
||
<div className="question-form-section">
|
||
<label className="question-form-label">{t('editor.choicesCheckCorrect')}</label>
|
||
<div className="choice-editor-list">
|
||
{choices.map((c, i) => (
|
||
<div key={c.id} className="choice-editor-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={answer.includes(c.id)}
|
||
onChange={() => toggleAnswer(c.id)}
|
||
/>
|
||
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
|
||
<input
|
||
className="input"
|
||
value={c.text}
|
||
onChange={(e) => updateChoice(i, e.target.value)}
|
||
placeholder={t('editor.choicePlaceholder', { id: c.id })}
|
||
/>
|
||
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
|
||
{t('editor.addChoice')}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Text Answer (short, translation, math) ─── */
|
||
|
||
function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||
const { t } = useTranslation()
|
||
const accepted = q.acceptedForms ?? []
|
||
return (
|
||
<div className="question-form-section">
|
||
<div className="question-form-row">
|
||
<label className="question-form-label">{t('editor.correctAnswer')}</label>
|
||
<input
|
||
className="input"
|
||
value={(q.answer as string) ?? ''}
|
||
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
|
||
placeholder={t('editor.correctAnswerPlaceholder')}
|
||
/>
|
||
</div>
|
||
<div className="question-form-row">
|
||
<label className="question-form-label">{t('editor.acceptedForms')}</label>
|
||
{accepted.map((form, i) => (
|
||
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||
<input
|
||
className="input"
|
||
value={form}
|
||
onChange={(e) => {
|
||
const next = [...accepted]
|
||
next[i] = e.target.value
|
||
onChange((q) => { q.acceptedForms = next })
|
||
}}
|
||
placeholder={t('editor.alternativeAnswer')}
|
||
/>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={() => {
|
||
const next = accepted.filter((_, idx) => idx !== i)
|
||
onChange((q) => { q.acceptedForms = next })
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => onChange((q) => { q.acceptedForms = [...(q.acceptedForms ?? []), ''] })}
|
||
style={{ marginTop: 4 }}
|
||
>
|
||
{t('editor.addAlternative')}
|
||
</button>
|
||
</div>
|
||
<div className="question-form-row-inline" style={{ marginTop: 8 }}>
|
||
<label className="question-form-check">
|
||
<input
|
||
type="checkbox"
|
||
checked={q.caseSensitive ?? false}
|
||
onChange={(e) => onChange((q) => { q.caseSensitive = e.target.checked })}
|
||
/>
|
||
{t('editor.caseSensitive')}
|
||
</label>
|
||
<label className="question-form-check">
|
||
<input
|
||
type="checkbox"
|
||
checked={q.ignorePunctuation ?? false}
|
||
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
|
||
/>
|
||
{t('editor.ignorePunctuation')}
|
||
</label>
|
||
<label className="question-form-check">
|
||
<input
|
||
type="checkbox"
|
||
checked={q.ignoreAccents ?? false}
|
||
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
|
||
/>
|
||
{t('editor.ignoreAccents')}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Essay ─── */
|
||
|
||
function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||
const { t } = useTranslation()
|
||
const rubric = q.rubric ?? []
|
||
return (
|
||
<div className="question-form-section">
|
||
<div className="question-form-row-inline">
|
||
<div style={{ width: 140 }}>
|
||
<label className="question-form-label">{t('editor.minWords')}</label>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={0}
|
||
value={q.minWords ?? 0}
|
||
onChange={(e) => onChange((q) => { q.minWords = Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="question-form-row">
|
||
<label className="question-form-label">{t('editor.rubricCriteria')}</label>
|
||
{rubric.map((item, i) => (
|
||
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||
<input
|
||
className="input"
|
||
value={item}
|
||
onChange={(e) => {
|
||
const next = [...rubric]
|
||
next[i] = e.target.value
|
||
onChange((q) => { q.rubric = next })
|
||
}}
|
||
placeholder={t('editor.criterion')}
|
||
/>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={() => {
|
||
const next = rubric.filter((_, idx) => idx !== i)
|
||
onChange((q) => { q.rubric = next })
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
|
||
style={{ marginTop: 4 }}
|
||
>
|
||
{t('editor.addCriterion')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Match ─── */
|
||
|
||
function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||
const { t } = useTranslation()
|
||
const left = q.left ?? []
|
||
const right = q.right ?? []
|
||
const answer = (q.answer as Record<string, string>) ?? {}
|
||
|
||
const addLeft = () => {
|
||
const id = `l${left.length + 1}`
|
||
onChange((q) => { q.left = [...left, { id, text: '' }] })
|
||
}
|
||
const addRight = () => {
|
||
const id = `r${right.length + 1}`
|
||
onChange((q) => { q.right = [...right, { id, text: '' }] })
|
||
}
|
||
const removeLeft = (idx: number) => {
|
||
const removed = left[idx]
|
||
onChange((q) => {
|
||
q.left = left.filter((_, i) => i !== idx)
|
||
const ans = { ...(q.answer as Record<string, string>) }
|
||
delete ans[removed.id]
|
||
q.answer = ans
|
||
})
|
||
}
|
||
const removeRight = (idx: number) => {
|
||
const removed = right[idx]
|
||
onChange((q) => {
|
||
q.right = right.filter((_, i) => i !== idx)
|
||
const ans = { ...(q.answer as Record<string, string>) }
|
||
for (const k of Object.keys(ans)) {
|
||
if (ans[k] === removed.id) delete ans[k]
|
||
}
|
||
q.answer = ans
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="question-form-section">
|
||
<div className="match-editor-grid">
|
||
<div>
|
||
<label className="question-form-label">{t('editor.leftColumn')}</label>
|
||
{left.map((item, i) => (
|
||
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||
<input
|
||
className="input"
|
||
value={item.text}
|
||
onChange={(e) => {
|
||
const next = [...left]
|
||
next[i] = { ...next[i], text: e.target.value }
|
||
onChange((q) => { q.left = next })
|
||
}}
|
||
placeholder={t('editor.leftItemPlaceholder', { index: i + 1 })}
|
||
/>
|
||
<button className="btn btn-ghost btn-sm" onClick={() => removeLeft(i)}>✕</button>
|
||
</div>
|
||
))}
|
||
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>{t('editor.addLeft')}</button>
|
||
</div>
|
||
<div>
|
||
<label className="question-form-label">{t('editor.rightColumn')}</label>
|
||
{right.map((item, i) => (
|
||
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||
<input
|
||
className="input"
|
||
value={item.text}
|
||
onChange={(e) => {
|
||
const next = [...right]
|
||
next[i] = { ...next[i], text: e.target.value }
|
||
onChange((q) => { q.right = next })
|
||
}}
|
||
placeholder={t('editor.rightItemPlaceholder', { index: i + 1 })}
|
||
/>
|
||
<button className="btn btn-ghost btn-sm" onClick={() => removeRight(i)}>✕</button>
|
||
</div>
|
||
))}
|
||
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>{t('editor.addRight')}</button>
|
||
</div>
|
||
</div>
|
||
|
||
{left.length > 0 && right.length > 0 && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<label className="question-form-label">{t('editor.correctPairings')}</label>
|
||
{left.map((l) => (
|
||
<div key={l.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||
<span style={{ minWidth: 120, fontSize: 14 }}>{l.text || l.id}</span>
|
||
<span className="faint">→</span>
|
||
<select
|
||
className="input"
|
||
style={{ width: 200 }}
|
||
value={answer[l.id] ?? ''}
|
||
onChange={(e) => {
|
||
const next = { ...answer, [l.id]: e.target.value }
|
||
if (!e.target.value) delete next[l.id]
|
||
onChange((q) => { q.answer = next })
|
||
}}
|
||
>
|
||
<option value="">{t('editor.select')}</option>
|
||
{right.map((r) => (
|
||
<option key={r.id} value={r.id}>{r.text || r.id}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ─── Order ─── */
|
||
|
||
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||
const { t } = useTranslation()
|
||
const items = q.items ?? []
|
||
const answer = (q.answer as string[]) ?? []
|
||
|
||
const addItem = () => {
|
||
const id = `s${items.length + 1}`
|
||
onChange((q) => { q.items = [...items, { id, text: '' }] })
|
||
}
|
||
|
||
const removeItem = (idx: number) => {
|
||
const removed = items[idx]
|
||
onChange((q) => {
|
||
q.items = items.filter((_, i) => i !== idx)
|
||
q.answer = answer.filter((a) => a !== removed.id)
|
||
})
|
||
}
|
||
|
||
const moveItem = (idx: number, dir: -1 | 1) => {
|
||
const next = [...items]
|
||
const swap = idx + dir
|
||
if (swap < 0 || swap >= next.length) return
|
||
;[next[idx], next[swap]] = [next[swap], next[idx]]
|
||
onChange((q) => { q.items = next })
|
||
}
|
||
|
||
return (
|
||
<div className="question-form-section">
|
||
<label className="question-form-label">{t('editor.items')}</label>
|
||
{items.map((item, i) => (
|
||
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||
<span className="mono faint" style={{ fontSize: 12, width: 24 }}>{i + 1}</span>
|
||
<input
|
||
className="input"
|
||
value={item.text}
|
||
onChange={(e) => {
|
||
const next = [...items]
|
||
next[i] = { ...next[i], text: e.target.value }
|
||
onChange((q) => { q.items = next })
|
||
}}
|
||
placeholder={t('editor.itemPlaceholder', { index: i + 1 })}
|
||
/>
|
||
<div className="order-editor-controls">
|
||
<button className="btn btn-ghost btn-sm" disabled={i === 0} onClick={() => moveItem(i, -1)}>↑</button>
|
||
<button className="btn btn-ghost btn-sm" disabled={i === items.length - 1} onClick={() => moveItem(i, 1)}>↓</button>
|
||
<button className="btn btn-ghost btn-sm" onClick={() => removeItem(i)}>✕</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<button className="btn btn-sm" onClick={addItem} style={{ marginTop: 4 }}>{t('editor.addItem')}</button>
|
||
|
||
{items.length > 0 && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<label className="question-form-label">{t('editor.correctOrder')}</label>
|
||
<div className="choice-editor-row">
|
||
{items.map((item, i) => (
|
||
<span key={item.id} className="mono faint" style={{ fontSize: 13, marginRight: 4 }}>
|
||
{i + 1}. {item.text || item.id}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
|
||
{t('editor.correctOrderHint')}
|
||
</p>
|
||
{/* Sync answer to current item order */}
|
||
<button
|
||
className="btn btn-sm"
|
||
style={{ marginTop: 8 }}
|
||
onClick={() => onChange((q) => { q.answer = items.map((it) => it.id) })}
|
||
>
|
||
{t('editor.setCurrentOrder')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|