Add internationalization support with English and Dutch translations

- 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.
This commit is contained in:
LaurensBartelds committed 2026-04-26 21:19:47 +02:00
1 parent c64cdcd9e3
commit 6277826472
19 files changed
+1193 -369

No files matched your search

+84 -75
View File
@@ -1,21 +1,22 @@
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; label: string }[] = [
{ value: 'multiple-choice', label: 'Multiple Choice' },
{ value: 'multiple-select', label: 'Multiple Select' },
{ value: 'short-answer', label: 'Short Answer' },
{ value: 'translation', label: 'Translation' },
{ value: 'math-input', label: 'Math Input' },
{ value: 'essay', label: 'Essay' },
{ value: 'match', label: 'Match' },
{ value: 'order', label: 'Order' },
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: 'Untitled Exam',
title: '',
subtitle: '',
questions: [],
}
@@ -44,6 +45,7 @@ function makeBlankQuestion(type: QuestionType, index: number): Question {
}
export default function ExamEditor() {
const { t } = useTranslation()
const { id: paramId } = useParams<{ id: string }>()
const navigate = useNavigate()
const isNew = paramId === 'new'
@@ -99,11 +101,11 @@ export default function ExamEditor() {
const saveExam = async () => {
if (!exam.title.trim()) {
setError('Exam title is required')
setError(t('editor.examTitleRequired'))
return
}
if (exam.questions.length === 0) {
setError('Add at least one question')
setError(t('editor.atLeastOneQuestion'))
return
}
@@ -112,12 +114,12 @@ export default function ExamEditor() {
try {
if (isNew) {
const data = await examApi.createExam(exam)
setSaveMsg('Exam created successfully')
setSaveMsg(t('editor.examCreated'))
setHasChanges(false)
navigate(`/exam-editor/${data.id}`, { replace: true })
} else {
await examApi.updateExam(examId!, exam)
setSaveMsg('Exam saved')
setSaveMsg(t('editor.examSaved'))
setHasChanges(false)
}
setTimeout(() => setSaveMsg(null), 3000)
@@ -138,20 +140,20 @@ export default function ExamEditor() {
<header className="exam-editor-header">
<div className="exam-editor-header-left">
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/exams')}>
← Back
{t('editor.back')}
</button>
<input
className="exam-editor-title-input"
value={exam.title}
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
placeholder="Exam title"
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">Unsaved changes</span>}
{hasChanges && <span className="exam-editor-unsaved">{t('editor.unsavedChanges')}</span>}
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
{saving ? 'Saving…' : isNew ? 'Create Exam' : 'Save'}
{saving ? t('editor.saving') : isNew ? t('editor.createExam') : t('editor.save')}
</button>
</div>
</header>
@@ -164,12 +166,12 @@ export default function ExamEditor() {
)}
{loading ? (
<div className="exam-editor-loading">Loading…</div>
<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">Questions</div>
<div className="sidebar-section-label">{t('editor.questionsLabel')}</div>
<div className="nav-list">
{exam.questions.map((q, i) => (
<button
@@ -182,7 +184,7 @@ export default function ExamEditor() {
<span
className="exam-editor-q-remove"
onClick={(e) => { e.stopPropagation(); removeQuestion(i) }}
title="Remove"
title={t('common.delete')}
>
✕
</span>
@@ -191,15 +193,15 @@ export default function ExamEditor() {
</div>
<div className="exam-editor-add">
<div className="sidebar-section-label" style={{ marginTop: 24 }}>Add Question</div>
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
<div className="exam-editor-type-grid">
{QUESTION_TYPES.map((t) => (
{QUESTION_TYPES.map((tType) => (
<button
key={t.value}
key={tType.value}
className="exam-editor-type-btn"
onClick={() => addQuestion(t.value)}
onClick={() => addQuestion(tType.value)}
>
{t.label}
{t(tType.labelKey)}
</button>
))}
</div>
@@ -221,8 +223,8 @@ export default function ExamEditor() {
) : (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">No questions yet</div>
<div className="workspace-empty-desc">Select a question type from the sidebar to add your first question.</div>
<div className="workspace-empty-title">{t('editor.noQuestions')}</div>
<div className="workspace-empty-desc">{t('editor.noQuestionsDesc')}</div>
</div>
)}
</main>
@@ -240,6 +242,7 @@ interface QEProps {
}
function QuestionEditor({ question, onChange }: QEProps) {
const { t } = useTranslation()
const update = <K extends keyof Question>(key: K, value: Question[K]) => {
onChange((q) => { q[key] = value })
}
@@ -247,33 +250,33 @@ function QuestionEditor({ question, onChange }: QEProps) {
return (
<div className="question-form">
<div className="question-form-header">
<span className="mono faint" style={{ fontSize: 12 }}>Question Editor</span>
<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">Prompt</label>
<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="Enter the question prompt..."
placeholder={t('editor.promptPlaceholder')}
/>
</div>
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Category (optional)</label>
<label className="question-form-label">{t('editor.categoryOptional')}</label>
<input
className="input"
value={question.category ?? ''}
onChange={(e) => update('category', e.target.value || undefined)}
placeholder="e.g. Algebra"
placeholder={t('editor.categoryPlaceholder')}
/>
</div>
<div style={{ width: 100 }}>
<label className="question-form-label">Points</label>
<label className="question-form-label">{t('editor.points')}</label>
<input
className="input"
type="number"
@@ -285,13 +288,13 @@ function QuestionEditor({ question, onChange }: QEProps) {
</div>
<div className="question-form-row">
<label className="question-form-label">Explanation (optional)</label>
<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="Explanation shown after grading..."
placeholder={t('editor.explanationPlaceholder')}
/>
</div>
@@ -304,12 +307,12 @@ function QuestionEditor({ question, onChange }: QEProps) {
{question.type === 'translation' && (
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Source Language</label>
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder="e.g. English" />
<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">Target Language</label>
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder="e.g. Spanish" />
<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>
)}
@@ -323,6 +326,7 @@ function QuestionEditor({ question, onChange }: QEProps) {
/* ─── 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 })
@@ -348,7 +352,7 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
return (
<div className="question-form-section">
<label className="question-form-label">Choices</label>
<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">
@@ -357,21 +361,21 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
name="mc-answer"
checked={q.answer === c.id}
onChange={() => setAnswer(c.id)}
title="Mark as correct"
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={`Choice ${c.id}`}
placeholder={t('editor.choicePlaceholder', { id: c.id })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
<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 }}>
+ Add Choice
{t('editor.addChoice')}
</button>
</div>
)
@@ -380,6 +384,7 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
/* ─── 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 })
@@ -409,7 +414,7 @@ function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps[
return (
<div className="question-form-section">
<label className="question-form-label">Choices (check correct answers)</label>
<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">
@@ -423,14 +428,14 @@ function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps[
className="input"
value={c.text}
onChange={(e) => updateChoice(i, e.target.value)}
placeholder={`Choice ${c.id}`}
placeholder={t('editor.choicePlaceholder', { id: c.id })}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
<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 }}>
+ Add Choice
{t('editor.addChoice')}
</button>
</div>
)
@@ -439,20 +444,21 @@ function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps[
/* ─── 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">Correct Answer</label>
<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="Enter the correct answer"
placeholder={t('editor.correctAnswerPlaceholder')}
/>
</div>
<div className="question-form-row">
<label className="question-form-label">Accepted Forms (optional alternatives)</label>
<label className="question-form-label">{t('editor.acceptedForms')}</label>
{accepted.map((form, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
@@ -463,7 +469,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
next[i] = e.target.value
onChange((q) => { q.acceptedForms = next })
}}
placeholder="Alternative answer"
placeholder={t('editor.alternativeAnswer')}
/>
<button
className="btn btn-ghost btn-sm"
@@ -481,7 +487,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
onClick={() => onChange((q) => { q.acceptedForms = [...(q.acceptedForms ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Alternative
{t('editor.addAlternative')}
</button>
</div>
<div className="question-form-row-inline" style={{ marginTop: 8 }}>
@@ -491,7 +497,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
checked={q.caseSensitive ?? false}
onChange={(e) => onChange((q) => { q.caseSensitive = e.target.checked })}
/>
Case sensitive
{t('editor.caseSensitive')}
</label>
<label className="question-form-check">
<input
@@ -499,7 +505,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
checked={q.ignorePunctuation ?? false}
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
/>
Ignore punctuation
{t('editor.ignorePunctuation')}
</label>
<label className="question-form-check">
<input
@@ -507,7 +513,7 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
checked={q.ignoreAccents ?? false}
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
/>
Ignore accents
{t('editor.ignoreAccents')}
</label>
</div>
</div>
@@ -517,12 +523,13 @@ function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onC
/* ─── 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">Min Words</label>
<label className="question-form-label">{t('editor.minWords')}</label>
<input
className="input"
type="number"
@@ -533,7 +540,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
</div>
</div>
<div className="question-form-row">
<label className="question-form-label">Rubric Criteria</label>
<label className="question-form-label">{t('editor.rubricCriteria')}</label>
{rubric.map((item, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
@@ -544,7 +551,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = e.target.value
onChange((q) => { q.rubric = next })
}}
placeholder="Criterion"
placeholder={t('editor.criterion')}
/>
<button
className="btn btn-ghost btn-sm"
@@ -562,7 +569,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Criterion
{t('editor.addCriterion')}
</button>
</div>
</div>
@@ -572,6 +579,7 @@ function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
/* ─── 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>) ?? {}
@@ -609,7 +617,7 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
<div className="question-form-section">
<div className="match-editor-grid">
<div>
<label className="question-form-label">Left Column</label>
<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
@@ -620,15 +628,15 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.left = next })
}}
placeholder={`Left item ${i + 1}`}
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 }}>+ Add Left</button>
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>{t('editor.addLeft')}</button>
</div>
<div>
<label className="question-form-label">Right Column</label>
<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
@@ -639,18 +647,18 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.right = next })
}}
placeholder={`Right item ${i + 1}`}
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 }}>+ Add Right</button>
<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">Correct Pairings</label>
<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>
@@ -665,7 +673,7 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
onChange((q) => { q.answer = next })
}}
>
<option value="">— Select —</option>
<option value="">{t('editor.select')}</option>
{right.map((r) => (
<option key={r.id} value={r.id}>{r.text || r.id}</option>
))}
@@ -681,6 +689,7 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
/* ─── Order ─── */
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const { t } = useTranslation()
const items = q.items ?? []
const answer = (q.answer as string[]) ?? []
@@ -707,7 +716,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
return (
<div className="question-form-section">
<label className="question-form-label">Items (drag not implemented, use arrows to reorder)</label>
<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>
@@ -719,7 +728,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.items = next })
}}
placeholder={`Item ${i + 1}`}
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>
@@ -728,11 +737,11 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
</div>
</div>
))}
<button className="btn btn-sm" onClick={addItem} style={{ marginTop: 4 }}>+ Add Item</button>
<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">Correct Order</label>
<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 }}>
@@ -741,7 +750,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
))}
</div>
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
The correct order is the order shown above. Reorder items to set the correct sequence.
{t('editor.correctOrderHint')}
</p>
{/* Sync answer to current item order */}
<button
@@ -749,7 +758,7 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
style={{ marginTop: 8 }}
onClick={() => onChange((q) => { q.answer = items.map((it) => it.id) })}
>
Set Current Order as Correct
{t('editor.setCurrentOrder')}
</button>
</div>
)}