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
@@ -20,6 +20,14 @@ const generateSchema = z.object({
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
count: z.number().int().min(1).max(10),
|
||||
language: z.string().min(1).max(20),
|
||||
|
||||
@@ -10,6 +10,14 @@ export type QuestionType =
|
||||
| 'essay'
|
||||
| 'match'
|
||||
| 'order'
|
||||
| 'fill-in-the-blanks'
|
||||
| 'true-false'
|
||||
| 'code-submission'
|
||||
| 'dropdown-completion'
|
||||
| 'numeric-range'
|
||||
| 'dictation'
|
||||
| 'grammar-correction'
|
||||
| 'flashcard'
|
||||
|
||||
const choiceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -31,6 +39,14 @@ const generatedQuestionSchema = z.object({
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
prompt: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
@@ -47,6 +63,35 @@ const generatedQuestionSchema = z.object({
|
||||
left: z.array(matchItemSchema).optional(),
|
||||
right: z.array(matchItemSchema).optional(),
|
||||
items: z.array(matchItemSchema).optional(),
|
||||
textSegments: z.array(z.string()).optional(),
|
||||
blanks: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
acceptedAnswers: z.array(z.string()),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
ignorePunctuation: z.boolean().optional(),
|
||||
ignoreAccents: z.boolean().optional(),
|
||||
}),
|
||||
).optional(),
|
||||
dropdowns: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
options: z.array(choiceSchema),
|
||||
correctOptionId: z.string(),
|
||||
}),
|
||||
).optional(),
|
||||
codeConfig: z.object({
|
||||
language: z.string(),
|
||||
starterCode: z.string().optional(),
|
||||
}).optional(),
|
||||
numericConfig: z.object({
|
||||
answer: z.number(),
|
||||
tolerance: z.number(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
}).optional(),
|
||||
textToCorrect: z.string().optional(),
|
||||
})
|
||||
|
||||
export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema>
|
||||
@@ -77,6 +122,22 @@ function shapeHintForType(type: QuestionType): string {
|
||||
return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).`
|
||||
case 'order':
|
||||
return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).`
|
||||
case 'fill-in-the-blanks':
|
||||
return `Required fields: prompt (string), textSegments (array of text strings to interleave with blanks), blanks (array of {id, acceptedAnswers} where acceptedAnswers is an array of correct strings for that blank). The number of textSegments must be blanks.length + 1.`
|
||||
case 'true-false':
|
||||
return `Required fields: prompt (string), answer (boolean, true or false).`
|
||||
case 'code-submission':
|
||||
return `Required fields: prompt (string), codeConfig ({language: string, starterCode?: string}), rubric (array of 3-5 short rubric criteria strings).`
|
||||
case 'dropdown-completion':
|
||||
return `Required fields: prompt (string), textSegments (array of text strings to interleave with dropdowns), dropdowns (array of {id, options: [{id, text}], correctOptionId}). The number of textSegments must be dropdowns.length + 1.`
|
||||
case 'numeric-range':
|
||||
return `Required fields: prompt (string), numericConfig ({answer: number, tolerance: number, min?: number, max?: number}), answer (same number as numericConfig.answer).`
|
||||
case 'dictation':
|
||||
return `Required fields: prompt (string), audio ({src: string, a URL to an audio file}), answer (the correct text), acceptedForms (array of acceptable alternate strings), caseSensitive (boolean, usually false).`
|
||||
case 'grammar-correction':
|
||||
return `Required fields: prompt (string), textToCorrect (the sentence with errors to fix), answer (the corrected sentence), acceptedForms (array of acceptable corrected forms), caseSensitive (boolean, usually false).`
|
||||
case 'flashcard':
|
||||
return `Required fields: prompt (the front of the card, e.g. a word or phrase), answer (the back of the card, e.g. translation or definition), explanation (optional, extra info to show after reveal).`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ describe('examSchema', () => {
|
||||
const types = [
|
||||
'multiple-choice', 'multiple-select', 'short-answer', 'translation',
|
||||
'math-input', 'essay', 'match', 'order',
|
||||
'fill-in-the-blanks', 'true-false', 'code-submission',
|
||||
'dropdown-completion', 'numeric-range',
|
||||
'dictation', 'grammar-correction', 'flashcard',
|
||||
]
|
||||
for (const type of types) {
|
||||
const result = examSchema.safeParse({
|
||||
@@ -75,7 +78,7 @@ describe('examSchema', () => {
|
||||
it('rejects unsupported question type', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'true-false', prompt: 'Q' }],
|
||||
questions: [{ id: '1', type: 'file-upload', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
@@ -91,10 +94,10 @@ describe('examSchema', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-string/array/record answer', () => {
|
||||
it('rejects non-string/array/record/number/boolean answer', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: 42 }],
|
||||
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: null }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
@@ -30,6 +30,33 @@ const questionTableSchema = z.object({
|
||||
rows: z.array(z.array(z.string())),
|
||||
})
|
||||
|
||||
const blankSchema = z.object({
|
||||
id: z.string(),
|
||||
acceptedAnswers: z.array(z.string()),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
ignorePunctuation: z.boolean().optional(),
|
||||
ignoreAccents: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const dropdownSchema = z.object({
|
||||
id: z.string(),
|
||||
options: z.array(choiceSchema),
|
||||
correctOptionId: z.string(),
|
||||
})
|
||||
|
||||
const codeConfigSchema = z.object({
|
||||
language: z.string(),
|
||||
starterCode: z.string().optional(),
|
||||
})
|
||||
|
||||
const numericConfigSchema = z.object({
|
||||
answer: z.number(),
|
||||
tolerance: z.number(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
})
|
||||
|
||||
const questionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum([
|
||||
@@ -41,6 +68,14 @@ const questionSchema = z.object({
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
category: z.string().optional(),
|
||||
points: z.number().optional(),
|
||||
@@ -51,7 +86,7 @@ const questionSchema = z.object({
|
||||
code: questionCodeSchema.optional(),
|
||||
table: questionTableSchema.optional(),
|
||||
svg: z.string().optional(),
|
||||
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())]).optional(),
|
||||
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string()), z.boolean(), z.number()]).optional(),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
ignorePunctuation: z.boolean().optional(),
|
||||
@@ -64,6 +99,12 @@ const questionSchema = z.object({
|
||||
left: z.array(matchItemSchema).optional(),
|
||||
right: z.array(matchItemSchema).optional(),
|
||||
items: z.array(matchItemSchema).optional(),
|
||||
textSegments: z.array(z.string()).optional(),
|
||||
blanks: z.array(blankSchema).optional(),
|
||||
dropdowns: z.array(dropdownSchema).optional(),
|
||||
codeConfig: codeConfigSchema.optional(),
|
||||
numericConfig: numericConfigSchema.optional(),
|
||||
textToCorrect: z.string().optional(),
|
||||
})
|
||||
|
||||
export const examSchema = z.object({
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="BrainWave">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" fill="none" stroke="#1f1f24" stroke-width="2" />
|
||||
<rect x="6" y="6" width="10" height="12" rx="1.5" fill="#1f1f24" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 271 B |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"downloadInstructions": "Download Instructions — How to create your own exam JSON",
|
||||
"downloadInstructionsIcon": "↓",
|
||||
"supportedTypes": "Supported types",
|
||||
"supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order",
|
||||
"supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order · fill-in-the-blanks · true-false · code-submission · dropdown-completion · numeric-range",
|
||||
"trySample": "Try a sample",
|
||||
"resumeTitle": "Resume in-progress exam",
|
||||
"resumeProgress": "{{answered}}/{{total}} answered",
|
||||
@@ -381,6 +381,23 @@
|
||||
"essay": "Essay",
|
||||
"match": "Match",
|
||||
"order": "Order",
|
||||
"fillInTheBlanks": "Fill in the Blanks",
|
||||
"trueFalse": "True / False",
|
||||
"codeSubmission": "Code Submission",
|
||||
"dropdownCompletion": "Dropdown Completion",
|
||||
"numericRange": "Numeric Range",
|
||||
"dictation": "Dictation",
|
||||
"grammarCorrection": "Grammar Correction",
|
||||
"flashcard": "Flashcard",
|
||||
"textToCorrect": "Text to Correct",
|
||||
"textToCorrectPlaceholder": "Enter the grammatically incorrect sentence…",
|
||||
"playbackSpeed": "Speed",
|
||||
"revealAnswer": "Reveal Answer",
|
||||
"didntKnow": "Didn't know",
|
||||
"hard": "Hard",
|
||||
"good": "Good",
|
||||
"flashcardFront": "Prompt (front of card)",
|
||||
"flashcardFrontPlaceholder": "Enter the prompt…",
|
||||
"correctAnswer": "Correct Answer",
|
||||
"correctAnswerPlaceholder": "Enter the correct answer",
|
||||
"acceptedForms": "Accepted Forms (optional alternatives)",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"downloadInstructions": "Download Instructies — Hoe maak je je eigen examen-JSON",
|
||||
"downloadInstructionsIcon": "↓",
|
||||
"supportedTypes": "Ondersteunde types",
|
||||
"supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken",
|
||||
"supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken · invulvragen · waar-onwaar · code-inzending · dropdown-vervolledigen · numeriek-bereik",
|
||||
"trySample": "Probeer een voorbeeld",
|
||||
"resumeTitle": "Hervat lopend examen",
|
||||
"resumeProgress": "{{answered}}/{{total}} beantwoord",
|
||||
@@ -379,6 +379,23 @@
|
||||
"essay": "Opstel",
|
||||
"match": "Koppelen",
|
||||
"order": "Rangschikken",
|
||||
"fillInTheBlanks": "Invulvragen",
|
||||
"trueFalse": "Waar / Onwaar",
|
||||
"codeSubmission": "Code Inzending",
|
||||
"dropdownCompletion": "Dropdown Vervolledigen",
|
||||
"numericRange": "Numeriek Bereik",
|
||||
"dictation": "Dictee",
|
||||
"grammarCorrection": "Grammaticacorrectie",
|
||||
"flashcard": "Flashcard",
|
||||
"textToCorrect": "Tekst te corrigeren",
|
||||
"textToCorrectPlaceholder": "Voer de grammaticaal incorrecte zin in…",
|
||||
"playbackSpeed": "Snelheid",
|
||||
"revealAnswer": "Toon antwoord",
|
||||
"didntKnow": "Wist ik niet",
|
||||
"hard": "Moeilijk",
|
||||
"good": "Goed",
|
||||
"flashcardFront": "Prompt (voorkant kaart)",
|
||||
"flashcardFrontPlaceholder": "Voer de prompt in…",
|
||||
"correctAnswer": "Correct antwoord",
|
||||
"correctAnswerPlaceholder": "Voer het correcte antwoord in",
|
||||
"acceptedForms": "Geaccepteerde vormen (optionele alternatieven)",
|
||||
|
||||
@@ -993,6 +993,185 @@ select {
|
||||
background: var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- True / False ---------- */
|
||||
.tf-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.tf-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
text-align: center;
|
||||
}
|
||||
.tf-btn:hover {
|
||||
border-color: var(--line-3);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.tf-btn.selected {
|
||||
border-color: var(--ink);
|
||||
background: var(--paper-2);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.tf-btn.correct {
|
||||
border-color: var(--correct);
|
||||
background: var(--correct-weak);
|
||||
}
|
||||
.tf-btn.wrong {
|
||||
border-color: var(--wrong);
|
||||
background: var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- Cloze (Fill-in-the-blanks & Dropdown) ---------- */
|
||||
.cloze-text {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
line-height: 2.2;
|
||||
color: var(--ink);
|
||||
}
|
||||
.cloze-blank {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
margin: 0 4px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
width 120ms ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cloze-blank:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.cloze-blank.correct {
|
||||
border-color: var(--correct);
|
||||
background: var(--correct-weak);
|
||||
}
|
||||
.cloze-blank.wrong {
|
||||
border-color: var(--wrong);
|
||||
background: var(--wrong-weak);
|
||||
}
|
||||
.cloze-dropdown {
|
||||
display: inline-block;
|
||||
padding: 5px 28px 5px 12px;
|
||||
margin: 0 4px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
vertical-align: middle;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
}
|
||||
.cloze-dropdown:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.cloze-dropdown.correct {
|
||||
border-color: var(--correct);
|
||||
background-color: var(--correct-weak);
|
||||
}
|
||||
.cloze-dropdown.wrong {
|
||||
border-color: var(--wrong);
|
||||
background-color: var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- Numeric Range ---------- */
|
||||
.numeric-input {
|
||||
max-width: 200px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
}
|
||||
.numeric-input.correct {
|
||||
border-color: var(--correct);
|
||||
background: var(--correct-weak);
|
||||
box-shadow: 0 0 0 3px var(--correct-weak);
|
||||
}
|
||||
.numeric-input.wrong {
|
||||
border-color: var(--wrong);
|
||||
background: var(--wrong-weak);
|
||||
box-shadow: 0 0 0 3px var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- Code Submission ---------- */
|
||||
.code-submission-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-submission-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.code-lang-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--paper-3);
|
||||
color: var(--ink-3);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.code-submission-area {
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 180px;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
tab-size: 4;
|
||||
}
|
||||
.code-submission-area:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.code-submission-hint {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Short / Math input ---------- */
|
||||
.answer-input-wrap {
|
||||
display: flex;
|
||||
@@ -3234,3 +3413,156 @@ select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Dictation ---------- */
|
||||
.dictation-audio-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 18px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dictation-play-btn {
|
||||
min-width: 90px;
|
||||
justify-content: center;
|
||||
}
|
||||
.dictation-play-btn.playing {
|
||||
background: var(--accent-weak);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.dictation-speed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dictation-speed-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dictation-speed-btn.active {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Grammar Correction ---------- */
|
||||
.grammar-text-wrap {
|
||||
padding: 14px 18px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grammar-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.grammar-sentence {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Flashcard ---------- */
|
||||
.flashcard-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.flashcard-prompt {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22px;
|
||||
line-height: 1.4;
|
||||
color: var(--ink);
|
||||
padding: 20px 24px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
text-align: center;
|
||||
}
|
||||
.flashcard-reveal-btn {
|
||||
align-self: center;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.flashcard-answer {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
color: var(--accent-ink);
|
||||
padding: 16px 20px;
|
||||
background: var(--accent-weak);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--r-md);
|
||||
text-align: center;
|
||||
}
|
||||
.flashcard-explanation {
|
||||
font-size: 13.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.5;
|
||||
padding: 12px 16px;
|
||||
background: var(--paper-2);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.flashcard-grade {
|
||||
padding: 14px 18px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.flashcard-grade-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.flashcard-grade-wrong {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.flashcard-grade-wrong:hover {
|
||||
background: var(--wrong-weak);
|
||||
border-color: var(--wrong);
|
||||
color: var(--wrong);
|
||||
}
|
||||
.flashcard-grade-hard {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.flashcard-grade-hard:hover {
|
||||
background: var(--warn-weak);
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.flashcard-grade-good {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.flashcard-grade-good:hover {
|
||||
background: var(--correct-weak);
|
||||
border-color: var(--correct);
|
||||
color: var(--correct);
|
||||
}
|
||||
.flashcard-grade-result {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--r-md);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
.flashcard-grade-result.correct {
|
||||
background: var(--correct-weak);
|
||||
color: var(--correct);
|
||||
border: 1px solid var(--correct);
|
||||
}
|
||||
.flashcard-grade-result.wrong {
|
||||
background: var(--wrong-weak);
|
||||
color: var(--wrong);
|
||||
border: 1px solid var(--wrong);
|
||||
}
|
||||
+43
-2
@@ -8,7 +8,42 @@ export type QuestionType =
|
||||
| "math-input"
|
||||
| "essay"
|
||||
| "match"
|
||||
| "order";
|
||||
| "order"
|
||||
| "fill-in-the-blanks"
|
||||
| "true-false"
|
||||
| "code-submission"
|
||||
| "dropdown-completion"
|
||||
| "numeric-range"
|
||||
| "dictation"
|
||||
| "grammar-correction"
|
||||
| "flashcard";
|
||||
|
||||
export interface Blank {
|
||||
id: string;
|
||||
acceptedAnswers: string[];
|
||||
acceptedForms?: string[];
|
||||
caseSensitive?: boolean;
|
||||
ignorePunctuation?: boolean;
|
||||
ignoreAccents?: boolean;
|
||||
}
|
||||
|
||||
export interface Dropdown {
|
||||
id: string;
|
||||
options: Choice[];
|
||||
correctOptionId: string;
|
||||
}
|
||||
|
||||
export interface CodeSubmissionConfig {
|
||||
language: string;
|
||||
starterCode?: string;
|
||||
}
|
||||
|
||||
export interface NumericConfig {
|
||||
answer: number;
|
||||
tolerance: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
id: string;
|
||||
@@ -52,7 +87,7 @@ export interface Question {
|
||||
code?: QuestionCode;
|
||||
table?: QuestionTable;
|
||||
svg?: string;
|
||||
answer?: string | string[] | Record<string, string>;
|
||||
answer?: string | string[] | Record<string, string> | boolean | number;
|
||||
acceptedForms?: string[];
|
||||
caseSensitive?: boolean;
|
||||
ignorePunctuation?: boolean;
|
||||
@@ -65,6 +100,12 @@ export interface Question {
|
||||
left?: MatchItem[];
|
||||
right?: MatchItem[];
|
||||
items?: MatchItem[];
|
||||
textSegments?: string[];
|
||||
blanks?: Blank[];
|
||||
dropdowns?: Dropdown[];
|
||||
codeConfig?: CodeSubmissionConfig;
|
||||
numericConfig?: NumericConfig;
|
||||
textToCorrect?: string;
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Question, GradeResult } from '../types'
|
||||
import type { Question, GradeResult, Blank } from '../types'
|
||||
|
||||
interface NormalizeOpts {
|
||||
caseSensitive?: boolean
|
||||
@@ -6,6 +6,12 @@ interface NormalizeOpts {
|
||||
ignorePunctuation?: boolean
|
||||
}
|
||||
|
||||
const blankOpts = (b: Blank): NormalizeOpts => ({
|
||||
caseSensitive: b.caseSensitive === true,
|
||||
ignorePunctuation: b.ignorePunctuation !== false,
|
||||
ignoreAccents: b.ignoreAccents !== false,
|
||||
})
|
||||
|
||||
export const normalizeString = (s: unknown, opts: NormalizeOpts = {}): string => {
|
||||
if (s == null) return ''
|
||||
let out = String(s)
|
||||
@@ -100,6 +106,73 @@ export const gradeQuestion = (q: Question, userAnswer: unknown): GradeResult =>
|
||||
}
|
||||
return { status: 'ungraded', correct: false, points: 0 }
|
||||
}
|
||||
case 'true-false': {
|
||||
const ok = userAnswer === q.answer
|
||||
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
|
||||
}
|
||||
case 'numeric-range': {
|
||||
const cfg = q.numericConfig
|
||||
if (!cfg) return { status: 'unanswered', correct: false, points: 0 }
|
||||
const num = Number(userAnswer)
|
||||
if (Number.isNaN(num)) return { status: 'wrong', correct: false, points: 0 }
|
||||
const ok = Math.abs(num - cfg.answer) <= cfg.tolerance
|
||||
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
|
||||
}
|
||||
case 'dropdown-completion': {
|
||||
const correct = (q.dropdowns ?? []).reduce<Record<string, string>>((acc, d) => {
|
||||
acc[d.id] = d.correctOptionId
|
||||
return acc
|
||||
}, {})
|
||||
const given = (userAnswer as Record<string, string>) ?? {}
|
||||
const keys = Object.keys(correct)
|
||||
if (keys.length === 0) return { status: 'unanswered', correct: false, points: 0 }
|
||||
const allOk = keys.every((k) => given[k] === correct[k])
|
||||
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? pts : 0 }
|
||||
}
|
||||
case 'fill-in-the-blanks': {
|
||||
const blanks = q.blanks ?? []
|
||||
if (blanks.length === 0) return { status: 'unanswered', correct: false, points: 0 }
|
||||
const given = (userAnswer as Record<string, string>) ?? {}
|
||||
let allOk = true
|
||||
for (const b of blanks) {
|
||||
const target = normalizeString(given[b.id], blankOpts(b))
|
||||
const accepted = [...b.acceptedAnswers, ...(b.acceptedForms ?? [])]
|
||||
.filter(Boolean)
|
||||
.map((a) => normalizeString(a, blankOpts(b)))
|
||||
if (!accepted.includes(target)) allOk = false
|
||||
}
|
||||
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? pts : 0 }
|
||||
}
|
||||
case 'code-submission': {
|
||||
const val = userAnswer as { code?: string; selfGrade?: number }
|
||||
if (val?.selfGrade != null) {
|
||||
const g = val.selfGrade
|
||||
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round(pts * g) }
|
||||
}
|
||||
return { status: 'ungraded', correct: false, points: 0 }
|
||||
}
|
||||
case 'dictation':
|
||||
case 'grammar-correction': {
|
||||
const opts: NormalizeOpts = {
|
||||
caseSensitive: q.caseSensitive === true,
|
||||
ignorePunctuation: q.ignorePunctuation !== false,
|
||||
ignoreAccents: q.ignoreAccents !== false,
|
||||
}
|
||||
const target = normalizeString(userAnswer, opts)
|
||||
const accepted = [q.answer as string, ...(q.acceptedForms ?? [])]
|
||||
.filter(Boolean)
|
||||
.map((a) => normalizeString(a, opts))
|
||||
const ok = accepted.includes(target)
|
||||
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
|
||||
}
|
||||
case 'flashcard': {
|
||||
const val = userAnswer as { selfGrade?: number }
|
||||
if (val?.selfGrade != null) {
|
||||
const g = val.selfGrade
|
||||
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round(pts * g) }
|
||||
}
|
||||
return { status: 'ungraded', correct: false, points: 0 }
|
||||
}
|
||||
default:
|
||||
return { status: 'unanswered', correct: false, points: 0 }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user