Public Access
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.
This commit is contained in:
1 parent
8420e6cae0
commit
be69db04a5
21 files changed
+6872
No files matched your search
@@ -0,0 +1,386 @@
|
||||
/* 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 });
|
||||
Reference in new issue
Block a user