Files
brainwave/docs/design_handoff_brainwave/app/questions.jsx
T
LaurensBartelds be69db04a5 Add SVG icons and TypeScript configuration files
- Introduced a new favicon.svg file for the application.
- Added icons.svg containing various SVG symbols for UI components.
- Created tsconfig.app.json for frontend TypeScript configuration with ES2023 target and bundler mode.
- Established tsconfig.json to reference app and node configurations.
- Implemented tsconfig.node.json for Node.js specific TypeScript settings.
2026-04-21 22:19:06 +02:00

387 lines
13 KiB
React

/* global React */
// ==============================================================
// questions.jsx — renderers for every question type
// ==============================================================
var { useState, useEffect, useRef, useMemo } = React;
// ---------- Media blocks (image, code, table, audio) ----------
const QuestionMedia = ({ q }) => (
<>
{q.image && (
<figure className="q-media">
<img src={q.image.src} alt={q.image.alt || ''} />
{q.image.caption && <figcaption className="q-media-caption">{q.image.caption}</figcaption>}
</figure>
)}
{q.audio && (
<div className="q-audio">
<audio controls src={q.audio.src}>Your browser does not support audio.</audio>
</div>
)}
{q.code && (
<pre
className="q-code"
dangerouslySetInnerHTML={{ __html: window.highlightCode(q.code.content, q.code.language) }}
/>
)}
{q.table && (
<table className="q-table">
<thead>
<tr>{q.table.headers.map((h, i) => <th key={i}>{h}</th>)}</tr>
</thead>
<tbody>
{q.table.rows.map((row, i) => (
<tr key={i}>{row.map((cell, j) => <td key={j}>{cell}</td>)}</tr>
))}
</tbody>
</table>
)}
{q.svg && <div className="q-media" dangerouslySetInnerHTML={{ __html: q.svg }} />}
</>
);
// ---------- Multiple Choice ----------
const MultipleChoice = ({ q, value, onChange, locked, graded }) => (
<div className="choices" role="radiogroup">
{q.choices.map((c, i) => {
const isSelected = value === c.id;
let cls = 'choice';
if (isSelected) cls += ' selected';
if (graded) {
if (c.id === q.answer) cls += ' correct';
else if (isSelected) cls += ' wrong';
}
return (
<button
key={c.id}
className={cls}
onClick={() => !locked && onChange(c.id)}
disabled={locked}
role="radio"
aria-checked={isSelected}
>
<span className="choice-marker" />
<span className="choice-key">{String.fromCharCode(65 + i)}</span>
<window.Tex>{c.text}</window.Tex>
</button>
);
})}
</div>
);
// ---------- Multiple Select ----------
const MultipleSelect = ({ q, value = [], onChange, locked, graded }) => {
const toggle = (id) => {
if (locked) return;
onChange(value.includes(id) ? value.filter((v) => v !== id) : [...value, id]);
};
const answerSet = new Set(q.answer || []);
return (
<div className="choices">
{q.choices.map((c, i) => {
const isSelected = value.includes(c.id);
let cls = 'choice';
if (isSelected) cls += ' selected';
if (graded) {
if (answerSet.has(c.id)) cls += ' correct';
else if (isSelected) cls += ' wrong';
}
return (
<button key={c.id} className={cls} onClick={() => toggle(c.id)} disabled={locked}>
<span className="choice-marker square" />
<span className="choice-key">{String.fromCharCode(65 + i)}</span>
<window.Tex>{c.text}</window.Tex>
</button>
);
})}
</div>
);
};
// ---------- Short Answer ----------
const ShortAnswer = ({ q, value = '', onChange, locked }) => (
<div className="answer-input-wrap">
<input
className="input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Type your answer…"
disabled={locked}
autoComplete="off"
spellCheck={q.type === 'translation'}
/>
{q.type === 'translation' && q.targetLanguage && (
<div className="faint mono" style={{ fontSize: 11 }}>
Answer in {q.targetLanguage}
</div>
)}
</div>
);
// ---------- Math Input ----------
const MathInput = ({ q, value = '', onChange, locked }) => {
const [preview, setPreview] = useState('');
useEffect(() => {
if (!value) { setPreview(''); return; }
try {
setPreview(window.katex.renderToString(value, { throwOnError: false, displayMode: false }));
} catch {
setPreview('');
}
}, [value]);
return (
<div className="answer-input-wrap">
<input
className="input mono"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="LaTeX, e.g. 2x\ln x + x"
disabled={locked}
autoComplete="off"
spellCheck={false}
/>
<div className="math-preview">
<span className="math-preview-label">Preview</span>
{preview
? <span dangerouslySetInnerHTML={{ __html: preview }} />
: <span className="faint" style={{ fontSize: 13 }}>Live LaTeX preview appears here</span>}
</div>
</div>
);
};
// ---------- Essay ----------
const Essay = ({ q, value = {}, onChange, locked }) => {
const text = typeof value === 'string' ? value : (value.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, 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>}</span>
<span>Self-graded · see rubric on review</span>
</div>
</div>
);
};
// ---------- Match ----------
const Match = ({ q, value = {}, onChange, locked, graded }) => {
const [activeLeft, setActiveLeft] = useState(null);
const reverseMap = useMemo(() => {
const m = {};
Object.entries(value).forEach(([l, r]) => { m[r] = l; });
return m;
}, [value]);
const handleRight = (rightId) => {
if (locked) return;
if (activeLeft) {
const next = { ...value };
// clear any existing pairing for this left or right
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]) {
// click right to clear its pairing
const next = { ...value };
delete next[reverseMap[rightId]];
onChange(next);
}
};
const handleLeft = (leftId) => {
if (locked) return;
if (value[leftId]) {
// click a paired left → clear it
const next = { ...value };
delete next[leftId];
onChange(next);
return;
}
setActiveLeft(activeLeft === leftId ? null : leftId);
};
const badge = (id, fromLeft) => {
if (fromLeft) {
const r = value[id];
if (!r) return null;
const idx = q.right.findIndex((x) => x.id === r);
return <span className="match-pair-badge">{String.fromCharCode(65 + idx)}</span>;
}
const l = reverseMap[id];
if (!l) return null;
const idx = q.left.findIndex((x) => x.id === l);
return <span className="match-pair-badge">{idx + 1}</span>;
};
const statusCls = (leftId, rightId) => {
if (!graded) return '';
const correctPair = q.answer[leftId];
if (correctPair && value[leftId] === correctPair) return ' correct';
if (value[leftId]) return ' wrong';
return '';
};
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 (value[item.id]) cls += ' paired';
cls += statusCls(item.id);
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>
<window.Tex>{item.text}</window.Tex>
</span>
{badge(item.id, true)}
</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';
// for graded: color right item based on whether the left it's paired with is correct
if (graded && reverseMap[item.id]) {
const l = reverseMap[item.id];
if (q.answer[l] === item.id) cls += ' correct';
else cls += ' wrong';
}
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>
<window.Tex>{item.text}</window.Tex>
</span>
{badge(item.id, false)}
</button>
);
})}
</div>
</div>
);
};
// ---------- Order (drag to reorder) ----------
const OrderQuestion = ({ q, value, onChange, locked, graded }) => {
// initialize value as shuffled item ids on first render
const initial = useMemo(() => {
if (Array.isArray(value) && value.length === q.items.length) return value;
const ids = q.items.map((i) => i.id);
// deterministic shuffle using question id as seed
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]);
useEffect(() => {
if (!Array.isArray(value) || value.length !== q.items.length) {
onChange(initial);
}
}, []);
const list = Array.isArray(value) && value.length === q.items.length ? value : initial;
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
const move = (from, to) => {
if (from === to || from < 0 || to < 0) return;
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
onChange(next);
};
const onDragStart = (idx) => (e) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (idx) => (e) => {
e.preventDefault();
setOverIdx(idx);
};
const onDrop = (idx) => (e) => {
e.preventDefault();
if (dragIdx != null && dragIdx !== idx) move(dragIdx, idx);
setDragIdx(null); setOverIdx(null);
};
const itemById = (id) => q.items.find((i) => i.id === id);
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 > idx ? ' drop-before' : ' drop-after';
if (graded) {
cls += q.answer[idx] === id ? ' correct' : ' wrong';
}
return (
<div
key={id}
className={cls}
draggable={!locked}
onDragStart={onDragStart(idx)}
onDragOver={onDragOver(idx)}
onDrop={onDrop(idx)}
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
>
<span className="order-handle">⋮⋮</span>
<span className="order-num">{idx + 1}</span>
<span className="order-text"><window.Tex>{itemById(id)?.text || ''}</window.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>
);
};
// ---------- Dispatcher ----------
const QuestionRenderer = ({ q, value, onChange, locked, graded }) => {
const props = { q, value, onChange, locked, graded };
switch (q.type) {
case 'multiple-choice': return <MultipleChoice {...props} />;
case 'multiple-select': return <MultipleSelect {...props} />;
case 'short-answer':
case 'translation': return <ShortAnswer {...props} />;
case 'math-input': return <MathInput {...props} />;
case 'essay': return <Essay {...props} />;
case 'match': return <Match {...props} />;
case 'order': return <OrderQuestion {...props} />;
default:
return <div className="muted">Unsupported question type: <code>{q.type}</code></div>;
}
};
Object.assign(window, { QuestionMedia, QuestionRenderer });