feat: add Essay, Match, and OrderQuestion renderers

This commit is contained in:
LaurensBartelds committed 2026-04-21 21:43:30 +02:00
1 parent 82ba53da0e
commit e48e37ad87
3 files changed
+231

No files matched your search

@@ -0,0 +1,39 @@
import type { Question } from '../../types'
interface EssayValue {
text?: string
selfGrade?: number
}
interface Props {
q: Question
value: EssayValue | string | null | undefined
onChange: (val: EssayValue) => void
locked?: boolean
}
export default function Essay({ q, value, onChange, locked }: Props) {
const text = typeof value === 'string' ? value : ((value as EssayValue)?.text ?? '')
const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0
const minOK = !q.minWords || wordCount >= q.minWords
return (
<div className="answer-input-wrap" style={{ maxWidth: 720 }}>
<textarea
className="textarea"
value={text}
onChange={(e) => onChange({ ...(value as EssayValue), text: e.target.value })}
placeholder="Write your response…"
disabled={locked}
rows={10}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }} className="mono faint">
<span>
{wordCount} words{q.minWords ? (
<span style={{ color: minOK ? 'var(--ink-3)' : 'var(--wrong)' }}> · min {q.minWords}</span>
) : null}
</span>
<span>Self-graded · see rubric on review</span>
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useState, useMemo } from 'react'
import Tex from '../Tex'
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 Match({ q, value, onChange, locked, graded }: Props) {
const current = value ?? {}
const [activeLeft, setActiveLeft] = useState<string | null>(null)
const answer = (q.answer as Record<string, string>) ?? {}
const reverseMap = useMemo(() => {
const m: Record<string, string> = {}
Object.entries(current).forEach(([l, r]) => { m[r] = l })
return m
}, [current])
const handleRight = (rightId: string) => {
if (locked) return
if (activeLeft) {
const next = { ...current }
Object.keys(next).forEach((k) => { if (k === activeLeft || next[k] === rightId) delete next[k] })
next[activeLeft] = rightId
onChange(next)
setActiveLeft(null)
} else if (reverseMap[rightId]) {
const next = { ...current }
delete next[reverseMap[rightId]]
onChange(next)
}
}
const handleLeft = (leftId: string) => {
if (locked) return
if (current[leftId]) {
const next = { ...current }
delete next[leftId]
onChange(next)
return
}
setActiveLeft(activeLeft === leftId ? null : leftId)
}
return (
<div className="match-grid">
<div className="match-col">
<div className="match-col-label">Items</div>
{(q.left ?? []).map((item, i) => {
let cls = 'match-item'
if (activeLeft === item.id) cls += ' active'
else if (current[item.id]) cls += ' paired'
if (graded) {
if (answer[item.id] && current[item.id] === answer[item.id]) cls += ' correct'
else if (current[item.id]) cls += ' wrong'
}
const pairedRightIdx = current[item.id]
? (q.right ?? []).findIndex((x) => x.id === current[item.id])
: -1
return (
<button key={item.id} className={cls} onClick={() => handleLeft(item.id)} disabled={locked}>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{i + 1}</span>
<Tex>{item.text}</Tex>
</span>
{pairedRightIdx >= 0 && (
<span className="match-pair-badge">{String.fromCharCode(65 + pairedRightIdx)}</span>
)}
</button>
)
})}
</div>
<div className="match-col">
<div className="match-col-label">Matches</div>
{(q.right ?? []).map((item, i) => {
let cls = 'match-item'
if (reverseMap[item.id]) cls += ' paired'
if (graded && reverseMap[item.id]) {
cls += answer[reverseMap[item.id]] === item.id ? ' correct' : ' wrong'
}
const pairedLeftIdx = reverseMap[item.id]
? (q.left ?? []).findIndex((x) => x.id === reverseMap[item.id])
: -1
return (
<button
key={item.id}
className={cls}
onClick={() => handleRight(item.id)}
disabled={locked || (!activeLeft && !reverseMap[item.id])}
>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{String.fromCharCode(65 + i)}</span>
<Tex>{item.text}</Tex>
</span>
{pairedLeftIdx >= 0 && (
<span className="match-pair-badge">{pairedLeftIdx + 1}</span>
)}
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,83 @@
import { useState, useMemo, useEffect } from 'react'
import Tex from '../Tex'
import type { Question } from '../../types'
interface Props {
q: Question
value: string[] | null | undefined
onChange: (val: string[]) => void
locked?: boolean
graded?: boolean
}
export default function OrderQuestion({ q, value, onChange, locked, graded }: Props) {
const items = q.items ?? []
const initial = useMemo(() => {
if (Array.isArray(value) && value.length === items.length) return value
const ids = items.map((i) => i.id)
const seed = q.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0)
const out = [...ids]
for (let i = out.length - 1; i > 0; i--) {
const j = (seed * (i + 7)) % (i + 1)
;[out[i], out[j]] = [out[j], out[i]]
}
return out
}, [q.id]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!Array.isArray(value) || value.length !== items.length) onChange(initial)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const list = Array.isArray(value) && value.length === items.length ? value : initial
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [overIdx, setOverIdx] = useState<number | null>(null)
const answer = (q.answer as string[]) ?? []
const move = (from: number, to: number) => {
if (from === to || from < 0 || to < 0) return
const next = [...list]
const [item] = next.splice(from, 1)
next.splice(to, 0, item)
onChange(next)
}
return (
<div className="order-list">
{list.map((id, idx) => {
let cls = 'order-item'
if (dragIdx === idx) cls += ' dragging'
if (overIdx === idx && dragIdx !== idx) {
cls += dragIdx !== null && dragIdx > idx ? ' drop-before' : ' drop-after'
}
if (graded) cls += answer[idx] === id ? ' correct' : ' wrong'
const item = items.find((i) => i.id === id)
return (
<div
key={id}
className={cls}
draggable={!locked}
onDragStart={(e) => { setDragIdx(idx); e.dataTransfer.effectAllowed = 'move' }}
onDragOver={(e) => { e.preventDefault(); setOverIdx(idx) }}
onDrop={(e) => {
e.preventDefault()
if (dragIdx != null && dragIdx !== idx) move(dragIdx, idx)
setDragIdx(null); setOverIdx(null)
}}
onDragEnd={() => { setDragIdx(null); setOverIdx(null) }}
>
<span className="order-handle">⋮⋮</span>
<span className="order-num">{idx + 1}</span>
<span className="order-text"><Tex>{item?.text ?? ''}</Tex></span>
{!locked && (
<div style={{ display: 'flex', gap: 2 }}>
<button className="btn btn-ghost btn-sm" onClick={() => move(idx, idx - 1)} disabled={idx === 0} aria-label="Move up">↑</button>
<button className="btn btn-ghost btn-sm" onClick={() => move(idx, idx + 1)} disabled={idx === list.length - 1} aria-label="Move down">↓</button>
</div>
)}
</div>
)
})}
</div>
)
}