Public Access
feat: add new question types and components for fill-in-the-blanks, true/false, code submission, dropdown completion, numeric range, dictation, grammar correction, and flashcards
- Updated QuestionRenderer to support new question types. - Added FillInTheBlanks, TrueFalse, CodeSubmission, DropdownCompletion, NumericRange, Dictation, GrammarCorrection, and Flashcard components. - Enhanced grading logic to handle new question types. - Updated translations for new question types in English and Dutch. - Added CSS styles for new components and question types. - Extended Question and related types to accommodate new configurations and answers.
This commit is contained in:
1 parent
a78fb8efc3
commit
e906951b06
20 files changed
+1518
-10
No files matched your search
@@ -14,6 +14,14 @@ const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||
{ value: 'essay', labelKey: 'editor.essay' },
|
||||
{ value: 'match', labelKey: 'editor.match' },
|
||||
{ value: 'order', labelKey: 'editor.order' },
|
||||
{ value: 'fill-in-the-blanks', labelKey: 'editor.fillInTheBlanks' },
|
||||
{ value: 'true-false', labelKey: 'editor.trueFalse' },
|
||||
{ value: 'code-submission', labelKey: 'editor.codeSubmission' },
|
||||
{ value: 'dropdown-completion', labelKey: 'editor.dropdownCompletion' },
|
||||
{ value: 'numeric-range', labelKey: 'editor.numericRange' },
|
||||
{ value: 'dictation', labelKey: 'editor.dictation' },
|
||||
{ value: 'grammar-correction', labelKey: 'editor.grammarCorrection' },
|
||||
{ value: 'flashcard', labelKey: 'editor.flashcard' },
|
||||
]
|
||||
|
||||
const BLANK_EXAM: Exam = {
|
||||
@@ -40,6 +48,22 @@ function makeBlankQuestion(type: QuestionType, index: number): Question {
|
||||
return { ...base, left: [], right: [], answer: {} }
|
||||
case 'order':
|
||||
return { ...base, items: [], answer: [] }
|
||||
case 'true-false':
|
||||
return { ...base, answer: true }
|
||||
case 'numeric-range':
|
||||
return { ...base, numericConfig: { answer: 0, tolerance: 0 } }
|
||||
case 'fill-in-the-blanks':
|
||||
return { ...base, textSegments: ['The capital of France is ', '.'], blanks: [{ id: 'b1', acceptedAnswers: ['Paris'] }] }
|
||||
case 'dropdown-completion':
|
||||
return { ...base, textSegments: ['The sky is ', ' during the day.'], dropdowns: [{ id: 'd1', options: [{ id: 'a', text: 'blue' }, { id: 'b', text: 'green' }], correctOptionId: 'a' }] }
|
||||
case 'code-submission':
|
||||
return { ...base, codeConfig: { language: 'python', starterCode: '' }, rubric: [] }
|
||||
case 'dictation':
|
||||
return { ...base, audio: { src: '' }, answer: '', acceptedForms: [], caseSensitive: false }
|
||||
case 'grammar-correction':
|
||||
return { ...base, textToCorrect: '', answer: '', acceptedForms: [], caseSensitive: false }
|
||||
case 'flashcard':
|
||||
return { ...base, answer: '' }
|
||||
default:
|
||||
return base
|
||||
}
|
||||
@@ -529,6 +553,14 @@ function QuestionEditor({ question, onChange }: QEProps) {
|
||||
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'match' && <MatchEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'order' && <OrderEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'true-false' && <TrueFalseEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'numeric-range' && <NumericRangeEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'fill-in-the-blanks' && <FillInTheBlanksEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'dropdown-completion' && <DropdownCompletionEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'code-submission' && <CodeSubmissionEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'dictation' && <DictationEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'grammar-correction' && <GrammarCorrectionEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'flashcard' && <FlashcardEditor q={question} onChange={onChange} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -895,6 +927,323 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── True/False ─── */
|
||||
|
||||
function TrueFalseEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">{t('editor.correctAnswer')}</label>
|
||||
<div className="choice-editor-row">
|
||||
<button
|
||||
className={`btn btn-sm${q.answer === true ? ' btn-primary' : ''}`}
|
||||
onClick={() => onChange((q) => { q.answer = true })}
|
||||
type="button"
|
||||
>
|
||||
True
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm${q.answer === false ? ' btn-primary' : ''}`}
|
||||
onClick={() => onChange((q) => { q.answer = false })}
|
||||
type="button"
|
||||
>
|
||||
False
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Numeric Range ─── */
|
||||
|
||||
function NumericRangeEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
const cfg = q.numericConfig ?? { answer: 0, tolerance: 0 }
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row-inline">
|
||||
<div>
|
||||
<label className="question-form-label">{t('editor.correctAnswer')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
value={cfg.answer}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, answer: Number(e.target.value) } })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 120 }}>
|
||||
<label className="question-form-label">Tolerance</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
min={0}
|
||||
value={cfg.tolerance}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, tolerance: Math.max(0, Number(e.target.value)) } })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 120 }}>
|
||||
<label className="question-form-label">Min (opt)</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
value={cfg.min ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, min: e.target.value ? Number(e.target.value) : undefined } })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 120 }}>
|
||||
<label className="question-form-label">Max (opt)</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
value={cfg.max ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, max: e.target.value ? Number(e.target.value) : undefined } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Fill in the Blanks ─── */
|
||||
|
||||
function FillInTheBlanksEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const segments = q.textSegments ?? []
|
||||
const blanks = q.blanks ?? []
|
||||
|
||||
const updateSegment = (i: number, text: string) => {
|
||||
const next = [...segments]
|
||||
next[i] = text
|
||||
onChange((q) => { q.textSegments = next })
|
||||
}
|
||||
|
||||
const addBlank = () => {
|
||||
const id = `b${blanks.length + 1}`
|
||||
onChange((q) => {
|
||||
q.textSegments = [...(q.textSegments ?? []), '']
|
||||
q.blanks = [...(q.blanks ?? []), { id, acceptedAnswers: [''] }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeBlank = (idx: number) => {
|
||||
onChange((q) => {
|
||||
q.textSegments = segments.filter((_, i) => i !== idx)
|
||||
q.blanks = blanks.filter((_, i) => i !== idx)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">Text segments & blanks</label>
|
||||
<p className="faint" style={{ fontSize: 12 }}>Text before blank 1, then blank 1 answer, then text before blank 2, etc.</p>
|
||||
{segments.map((seg, i) => (
|
||||
<div key={i} style={{ marginBottom: 12 }}>
|
||||
<div className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 60 }}>Text {i + 1}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={seg}
|
||||
onChange={(e) => updateSegment(i, e.target.value)}
|
||||
placeholder={`Text before blank ${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
{i < blanks.length && (
|
||||
<div className="choice-editor-row" style={{ marginBottom: 6, paddingLeft: 20 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 60 }}>Blank {i + 1}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={blanks[i].acceptedAnswers[0] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blanks]
|
||||
next[i] = { ...next[i], acceptedAnswers: [e.target.value] }
|
||||
onChange((q) => { q.blanks = next })
|
||||
}}
|
||||
placeholder="Accepted answer"
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeBlank(i)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={addBlank} style={{ marginTop: 4 }}>
|
||||
+ Add Blank
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Dropdown Completion ─── */
|
||||
|
||||
function DropdownCompletionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const segments = q.textSegments ?? []
|
||||
const dropdowns = q.dropdowns ?? []
|
||||
|
||||
const updateSegment = (i: number, text: string) => {
|
||||
const next = [...segments]
|
||||
next[i] = text
|
||||
onChange((q) => { q.textSegments = next })
|
||||
}
|
||||
|
||||
const addDropdown = () => {
|
||||
const id = `d${dropdowns.length + 1}`
|
||||
onChange((q) => {
|
||||
q.textSegments = [...(q.textSegments ?? []), '']
|
||||
q.dropdowns = [...(q.dropdowns ?? []), { id, options: [{ id: 'a', text: '' }, { id: 'b', text: '' }], correctOptionId: 'a' }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeDropdown = (idx: number) => {
|
||||
onChange((q) => {
|
||||
q.textSegments = segments.filter((_, i) => i !== idx)
|
||||
q.dropdowns = dropdowns.filter((_, i) => i !== idx)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">Text segments & dropdowns</label>
|
||||
<p className="faint" style={{ fontSize: 12 }}>Text before dropdown 1, then dropdown 1 options, etc.</p>
|
||||
{segments.map((seg, i) => (
|
||||
<div key={i} style={{ marginBottom: 12 }}>
|
||||
<div className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 80 }}>Text {i + 1}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={seg}
|
||||
onChange={(e) => updateSegment(i, e.target.value)}
|
||||
placeholder={`Text before dropdown ${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
{i < dropdowns.length && (
|
||||
<div style={{ paddingLeft: 20, marginBottom: 6 }}>
|
||||
<div className="choice-editor-row" style={{ marginBottom: 4 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 80 }}>Dropdown {i + 1}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeDropdown(i)}>✕</button>
|
||||
</div>
|
||||
{dropdowns[i].options.map((opt, j) => (
|
||||
<div key={opt.id} className="choice-editor-row" style={{ marginBottom: 4, paddingLeft: 20 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`dd-correct-${i}`}
|
||||
checked={dropdowns[i].correctOptionId === opt.id}
|
||||
onChange={() => {
|
||||
const next = [...dropdowns]
|
||||
next[i] = { ...next[i], correctOptionId: opt.id }
|
||||
onChange((q) => { q.dropdowns = next })
|
||||
}}
|
||||
title="Mark as correct"
|
||||
/>
|
||||
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{opt.id}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={opt.text}
|
||||
onChange={(e) => {
|
||||
const next = [...dropdowns]
|
||||
const opts = [...next[i].options]
|
||||
opts[j] = { ...opts[j], text: e.target.value }
|
||||
next[i] = { ...next[i], options: opts }
|
||||
onChange((q) => { q.dropdowns = next })
|
||||
}}
|
||||
placeholder={`Option ${opt.id}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ marginLeft: 20, marginTop: 4 }}
|
||||
onClick={() => {
|
||||
const next = [...dropdowns]
|
||||
const opts = [...next[i].options]
|
||||
const nextId = String.fromCharCode(97 + opts.length)
|
||||
opts.push({ id: nextId, text: '' })
|
||||
next[i] = { ...next[i], options: opts }
|
||||
onChange((q) => { q.dropdowns = next })
|
||||
}}
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={addDropdown} style={{ marginTop: 4 }}>
|
||||
+ Add Dropdown
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Code Submission ─── */
|
||||
|
||||
function CodeSubmissionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
const cfg = q.codeConfig ?? { language: 'python', starterCode: '' }
|
||||
const rubric = q.rubric ?? []
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row-inline">
|
||||
<div style={{ width: 160 }}>
|
||||
<label className="question-form-label">Language</label>
|
||||
<input
|
||||
className="input"
|
||||
value={cfg.language}
|
||||
onChange={(e) => onChange((q) => { q.codeConfig = { ...cfg, language: e.target.value } })}
|
||||
placeholder="e.g. python, javascript"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">Starter Code</label>
|
||||
<textarea
|
||||
className="textarea mono"
|
||||
value={cfg.starterCode ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.codeConfig = { ...cfg, starterCode: e.target.value } })}
|
||||
rows={6}
|
||||
placeholder="Code shown to the student at start…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Order ─── */
|
||||
|
||||
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
@@ -973,3 +1322,62 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Dictation ─── */
|
||||
|
||||
function DictationEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">{t('editor.dictation')}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={q.audio?.src ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.audio = { src: e.target.value } })}
|
||||
placeholder="https://example.com/audio.mp3"
|
||||
/>
|
||||
</div>
|
||||
<TextAnswerEditor q={q} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Grammar Correction ─── */
|
||||
|
||||
function GrammarCorrectionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">{t('editor.textToCorrect')}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={q.textToCorrect ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.textToCorrect = e.target.value })}
|
||||
placeholder={t('editor.textToCorrectPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<TextAnswerEditor q={q} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Flashcard ─── */
|
||||
|
||||
function FlashcardEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
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="The answer revealed on the back of the card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface CodeValue {
|
||||
code?: string
|
||||
selfGrade?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: CodeValue | string | null | undefined
|
||||
onChange: (val: CodeValue) => void
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export default function CodeSubmission({ q, value, onChange, locked }: Props) {
|
||||
const cfg = q.codeConfig
|
||||
const language = cfg?.language ?? 'text'
|
||||
const starterCode = cfg?.starterCode ?? ''
|
||||
|
||||
const val = typeof value === 'string' ? { code: value } : ((value as CodeValue) ?? {})
|
||||
const code = val.code ?? starterCode
|
||||
|
||||
return (
|
||||
<div className="code-submission-wrap" style={{ maxWidth: 720 }}>
|
||||
<div className="code-submission-bar">
|
||||
<span className="code-lang-badge">{language}</span>
|
||||
{cfg?.starterCode && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange({ ...val, code: cfg.starterCode })}
|
||||
disabled={locked}
|
||||
type="button"
|
||||
>
|
||||
Reset code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
className="code-submission-area"
|
||||
value={code}
|
||||
onChange={(e) => onChange({ ...val, code: e.target.value })}
|
||||
placeholder="Write your code here…"
|
||||
disabled={locked}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<div className="code-submission-hint mono faint">
|
||||
Self-graded · see rubric on review
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: string | null | undefined
|
||||
onChange: (val: string) => void
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export default function Dictation({ q, value = '', onChange, locked }: Props) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [playbackRate, setPlaybackRate] = useState(1)
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return
|
||||
if (playing) {
|
||||
audioRef.current.pause()
|
||||
} else {
|
||||
audioRef.current.playbackRate = playbackRate
|
||||
audioRef.current.play()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="answer-input-wrap">
|
||||
{q.audio?.src && (
|
||||
<div className="dictation-audio-wrap">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={q.audio.src}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
preload="metadata"
|
||||
/>
|
||||
<button
|
||||
className={`btn btn-sm dictation-play-btn${playing ? ' playing' : ''}`}
|
||||
onClick={togglePlay}
|
||||
type="button"
|
||||
disabled={locked}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
{playing ? '⏸ Pause' : '▶ Play'}
|
||||
</button>
|
||||
<div className="dictation-speed">
|
||||
<label className="faint" style={{ fontSize: 11 }}>Speed</label>
|
||||
{[0.5, 0.75, 1, 1.25, 1.5].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
className={`btn btn-ghost btn-sm dictation-speed-btn${playbackRate === rate ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setPlaybackRate(rate)
|
||||
if (audioRef.current) audioRef.current.playbackRate = rate
|
||||
}}
|
||||
type="button"
|
||||
disabled={locked}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Type what you hear…"
|
||||
disabled={locked}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: Record<string, string> | null | undefined
|
||||
onChange: (val: Record<string, string>) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function DropdownCompletion({ q, value, onChange, locked, graded }: Props) {
|
||||
const current = value ?? {}
|
||||
const segments = q.textSegments ?? []
|
||||
const dropdowns = q.dropdowns ?? []
|
||||
|
||||
const handleChange = (id: string, optionId: string) => {
|
||||
onChange({ ...current, [id]: optionId })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cloze-text" style={{ lineHeight: 2.2 }}>
|
||||
{segments.map((segment, i) => (
|
||||
<span key={`s-${i}`}>
|
||||
<span>{segment}</span>
|
||||
{i < dropdowns.length && (
|
||||
<DropdownSelect
|
||||
dropdown={dropdowns[i]}
|
||||
value={current[dropdowns[i].id] ?? ''}
|
||||
onChange={(v) => handleChange(dropdowns[i].id, v)}
|
||||
locked={locked}
|
||||
graded={graded}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownSelect({
|
||||
dropdown,
|
||||
value,
|
||||
onChange,
|
||||
locked,
|
||||
graded,
|
||||
}: {
|
||||
dropdown: { id: string; options: Array<{ id: string; text: string }>; correctOptionId: string }
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}) {
|
||||
let cls = 'cloze-dropdown'
|
||||
if (graded) {
|
||||
cls += value === dropdown.correctOptionId ? ' correct' : value ? ' wrong' : ''
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
className={cls}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={locked}
|
||||
>
|
||||
<option value="" disabled>
|
||||
—
|
||||
</option>
|
||||
{dropdown.options.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: Record<string, string> | null | undefined
|
||||
onChange: (val: Record<string, string>) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function FillInTheBlanks({ q, value, onChange, locked, graded }: Props) {
|
||||
const current = value ?? {}
|
||||
const blanks = q.blanks ?? []
|
||||
const segments = q.textSegments ?? []
|
||||
|
||||
const handleChange = (blankId: string, text: string) => {
|
||||
onChange({ ...current, [blankId]: text })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cloze-text" style={{ lineHeight: 2.2 }}>
|
||||
{segments.map((segment, i) => (
|
||||
<span key={`s-${i}`}>
|
||||
<span>{segment}</span>
|
||||
{i < blanks.length && (
|
||||
<BlankInput
|
||||
blank={blanks[i]}
|
||||
value={current[blanks[i].id] ?? ''}
|
||||
onChange={(v) => handleChange(blanks[i].id, v)}
|
||||
locked={locked}
|
||||
graded={graded}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlankInput({
|
||||
blank,
|
||||
value,
|
||||
onChange,
|
||||
locked,
|
||||
graded,
|
||||
}: {
|
||||
blank: { id: string; acceptedAnswers: string[] }
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}) {
|
||||
let cls = 'cloze-blank'
|
||||
if (graded) {
|
||||
// Simple check: if the normalized value matches any accepted answer
|
||||
const normalized = value.toLowerCase().trim()
|
||||
const ok = blank.acceptedAnswers.some((a) => a.toLowerCase().trim() === normalized)
|
||||
cls += ok ? ' correct' : ' wrong'
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={cls}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={locked}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: Math.max(80, (value.length + 2) * 10),
|
||||
minWidth: 80,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: { revealed?: boolean; selfGrade?: number } | null | undefined
|
||||
onChange: (val: { revealed?: boolean; selfGrade?: number }) => void
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export default function Flashcard({ q, value, onChange, locked }: Props) {
|
||||
const { revealed = false, selfGrade } = value ?? {}
|
||||
|
||||
const handleReveal = () => {
|
||||
if (locked) return
|
||||
onChange({ ...value, revealed: true })
|
||||
}
|
||||
|
||||
const handleGrade = (grade: number) => {
|
||||
if (locked) return
|
||||
onChange({ revealed: true, selfGrade: grade })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flashcard-wrap">
|
||||
<div className="flashcard-prompt">
|
||||
{q.prompt}
|
||||
</div>
|
||||
|
||||
{!revealed ? (
|
||||
<button
|
||||
className="btn flashcard-reveal-btn"
|
||||
onClick={handleReveal}
|
||||
disabled={locked}
|
||||
type="button"
|
||||
>
|
||||
Reveal Answer
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flashcard-answer">
|
||||
{q.answer as string}
|
||||
</div>
|
||||
{q.explanation && (
|
||||
<div className="flashcard-explanation">
|
||||
{q.explanation}
|
||||
</div>
|
||||
)}
|
||||
{selfGrade == null ? (
|
||||
<div className="flashcard-grade">
|
||||
<div className="faint" style={{ fontSize: 12, marginBottom: 6 }}>How well did you know this?</div>
|
||||
<div className="flashcard-grade-btns">
|
||||
<button className="btn btn-sm flashcard-grade-wrong" onClick={() => handleGrade(0)} type="button">
|
||||
Didn't know
|
||||
</button>
|
||||
<button className="btn btn-sm flashcard-grade-hard" onClick={() => handleGrade(0.5)} type="button">
|
||||
Hard
|
||||
</button>
|
||||
<button className="btn btn-sm flashcard-grade-good" onClick={() => handleGrade(1)} type="button">
|
||||
Good
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flashcard-grade-result ${selfGrade >= 0.5 ? 'correct' : 'wrong'}`}>
|
||||
{selfGrade >= 0.5 ? '✓ Marked as known' : '✗ Marked as needs practice'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: string | null | undefined
|
||||
onChange: (val: string) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function GrammarCorrection({ q, value = '', onChange, locked, graded }: Props) {
|
||||
return (
|
||||
<div className="answer-input-wrap">
|
||||
<div className="grammar-text-wrap">
|
||||
<div className="grammar-label faint" style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
Sentence to correct:
|
||||
</div>
|
||||
<div className="grammar-sentence">
|
||||
{q.textToCorrect || q.prompt}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Type the corrected sentence…"
|
||||
disabled={locked}
|
||||
autoComplete="off"
|
||||
spellCheck
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: number | string | null | undefined
|
||||
onChange: (val: number) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function NumericRange({ q, value, onChange, locked, graded }: Props) {
|
||||
const cfg = q.numericConfig
|
||||
if (!cfg) {
|
||||
return <div className="muted">Numeric config missing</div>
|
||||
}
|
||||
|
||||
const min = cfg.min
|
||||
const max = cfg.max
|
||||
const numValue = value === '' || value == null ? '' : Number(value)
|
||||
|
||||
let cls = 'input numeric-input'
|
||||
if (graded) {
|
||||
const ok = typeof numValue === 'number' && !Number.isNaN(numValue) && Math.abs(numValue - cfg.answer) <= cfg.tolerance
|
||||
cls += ok ? ' correct' : ' wrong'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="answer-input-wrap">
|
||||
<input
|
||||
className={cls}
|
||||
type="number"
|
||||
value={numValue}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v === '') return
|
||||
const n = Number(v)
|
||||
if (!Number.isNaN(n)) onChange(n)
|
||||
}}
|
||||
placeholder="Enter number…"
|
||||
disabled={locked}
|
||||
min={min}
|
||||
max={max}
|
||||
step="any"
|
||||
/>
|
||||
<div className="mono faint" style={{ fontSize: 11, marginTop: 4 }}>
|
||||
Tolerance: ±{cfg.tolerance}
|
||||
{min != null && max != null ? ` · Range: ${min} – ${max}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,14 @@ import MathInput from './MathInput'
|
||||
import Essay from './Essay'
|
||||
import Match from './Match'
|
||||
import OrderQuestion from './OrderQuestion'
|
||||
import FillInTheBlanks from './FillInTheBlanks'
|
||||
import TrueFalse from './TrueFalse'
|
||||
import CodeSubmission from './CodeSubmission'
|
||||
import DropdownCompletion from './DropdownCompletion'
|
||||
import NumericRange from './NumericRange'
|
||||
import Dictation from './Dictation'
|
||||
import GrammarCorrection from './GrammarCorrection'
|
||||
import Flashcard from './Flashcard'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
@@ -32,6 +40,22 @@ export default function QuestionRenderer({ q, value, onChange, locked, graded }:
|
||||
return <Match q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
|
||||
case 'order':
|
||||
return <OrderQuestion q={q} value={value as string[]} onChange={onChange as (v: string[]) => void} locked={locked} graded={graded} />
|
||||
case 'fill-in-the-blanks':
|
||||
return <FillInTheBlanks q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
|
||||
case 'true-false':
|
||||
return <TrueFalse q={q} value={value as boolean} onChange={onChange as (v: boolean) => void} locked={locked} graded={graded} />
|
||||
case 'code-submission':
|
||||
return <CodeSubmission q={q} value={value as Record<string, unknown>} onChange={onChange as (v: Record<string, unknown>) => void} locked={locked} />
|
||||
case 'dropdown-completion':
|
||||
return <DropdownCompletion q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
|
||||
case 'numeric-range':
|
||||
return <NumericRange q={q} value={value as number} onChange={onChange as (v: number) => void} locked={locked} graded={graded} />
|
||||
case 'dictation':
|
||||
return <Dictation q={q} value={value as string} onChange={onChange as (v: string) => void} locked={locked} />
|
||||
case 'grammar-correction':
|
||||
return <GrammarCorrection q={q} value={value as string} onChange={onChange as (v: string) => void} locked={locked} graded={graded} />
|
||||
case 'flashcard':
|
||||
return <Flashcard q={q} value={value as { revealed?: boolean; selfGrade?: number }} onChange={onChange as (v: { revealed?: boolean; selfGrade?: number }) => void} locked={locked} />
|
||||
default:
|
||||
return <div className="muted">Unsupported question type: <code>{q.type}</code></div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: boolean | null | undefined
|
||||
onChange: (val: boolean) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function TrueFalse({ q, value, onChange, locked, graded }: Props) {
|
||||
const correctAnswer = q.answer as boolean
|
||||
|
||||
return (
|
||||
<div className="tf-group" role="radiogroup">
|
||||
{[true, false].map((val) => {
|
||||
const isSelected = value === val
|
||||
let cls = 'tf-btn'
|
||||
if (isSelected) cls += ' selected'
|
||||
if (graded) {
|
||||
if (val === correctAnswer) cls += ' correct'
|
||||
else if (isSelected) cls += ' wrong'
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={val ? 't' : 'f'}
|
||||
className={cls}
|
||||
onClick={() => !locked && onChange(val)}
|
||||
disabled={locked}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
>
|
||||
<span className="tf-label">{val ? 'True' : 'False'}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user