Public Access
feat: add Essay, Match, and OrderQuestion renderers
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user