Public Access
- 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.
387 lines
13 KiB
React
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 });
|