Skip to content

Commit 14abf78

Browse files
authored
Merge pull request #7 from Lawndlwd/claude/issue-6-20260419-1612
feat: add multi_select question type for multiple correct answers
2 parents dbd49df + b03121e commit 14abf78

11 files changed

Lines changed: 335 additions & 71 deletions

File tree

client/src/pages/admin/CreateQuiz.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ const PLACEHOLDER = JSON.stringify(
2121
questionType: 'multiple_choice',
2222
imageUrl: 'https://example.com/image.jpg',
2323
},
24+
{
25+
text: 'Which of these are prime numbers?',
26+
options: ['2', '4', '7', '9'],
27+
correctIndex: 0,
28+
correctIndices: [0, 2],
29+
baseScore: 600,
30+
timeSec: 25,
31+
questionType: 'multi_select',
32+
},
2433
{
2534
text: 'The Earth is flat.',
2635
options: ['True', 'False'],
@@ -107,10 +116,17 @@ export default function CreateQuiz() {
107116
setJsonError(`Question ${i + 1} needs a correct answer`);
108117
return;
109118
}
110-
if (type === 'multiple_choice' && q.options.some((o) => !o.trim())) {
119+
if (
120+
(type === 'multiple_choice' || type === 'multi_select') &&
121+
q.options.some((o) => !o.trim())
122+
) {
111123
setJsonError(`Question ${i + 1} has empty options`);
112124
return;
113125
}
126+
if (type === 'multi_select' && (!q.correctIndices || q.correctIndices.length < 2)) {
127+
setJsonError(`Question ${i + 1} needs at least 2 correct answers selected`);
128+
return;
129+
}
114130
}
115131
await saveQuiz({ title, description, questions });
116132
}
@@ -154,7 +170,8 @@ export default function CreateQuiz() {
154170
<h2 className="mb-4">Paste JSON</h2>
155171
<div className="alert alert-info mb-4" style={{ fontSize: '0.85rem' }}>
156172
Use an AI (ChatGPT, Claude, etc.) to generate the JSON below. Supports{' '}
157-
<strong>multiple_choice</strong>, <strong>true_false</strong>, and{' '}
173+
<strong>multiple_choice</strong>, <strong>multi_select</strong> (use{' '}
174+
<code>correctIndices</code> array), <strong>true_false</strong>, and{' '}
158175
<strong>open_text</strong> question types.
159176
</div>
160177
<div className="form-group">

client/src/pages/admin/EditQuiz.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export default function EditQuiz() {
3535
text: string;
3636
options: string[];
3737
correct_index: number;
38+
correct_indices?: number[] | null;
3839
base_score: number;
3940
time_sec: number;
4041
image_url?: string;
@@ -44,6 +45,7 @@ export default function EditQuiz() {
4445
text: q.text,
4546
options: q.options,
4647
correctIndex: q.correct_index,
48+
correctIndices: q.correct_indices ?? undefined,
4749
baseScore: q.base_score,
4850
timeSec: q.time_sec,
4951
imageUrl: q.image_url ?? undefined,
@@ -78,10 +80,17 @@ export default function EditQuiz() {
7880
setError(`Question ${i + 1} needs a correct answer`);
7981
return;
8082
}
81-
if (type === 'multiple_choice' && q.options.some((o) => !o.trim())) {
83+
if (
84+
(type === 'multiple_choice' || type === 'multi_select') &&
85+
q.options.some((o) => !o.trim())
86+
) {
8287
setError(`Question ${i + 1} has empty options`);
8388
return;
8489
}
90+
if (type === 'multi_select' && (!q.correctIndices || q.correctIndices.length < 2)) {
91+
setError(`Question ${i + 1} needs at least 2 correct answers selected`);
92+
return;
93+
}
8594
}
8695

8796
setSaving(true);

client/src/pages/admin/components/QuestionEditor.tsx

Lines changed: 118 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,16 @@ export function QuestionEditor({ q, qi, onChange, onRemove, canRemove }: Props)
2828
if (t === 'true_false') {
2929
onChange('options', ['True', 'False']);
3030
onChange('correctIndex', 0);
31+
onChange('correctIndices', undefined);
3132
} else if (t === 'open_text') {
3233
onChange('options', []);
34+
onChange('correctIndices', undefined);
35+
} else if (t === 'multi_select') {
36+
if ((q.options ?? []).length < 2) onChange('options', ['', '', '', '']);
37+
onChange('correctIndices', [0]);
3338
} else {
3439
if ((q.options ?? []).length < 2) onChange('options', ['', '', '', '']);
40+
onChange('correctIndices', undefined);
3541
}
3642
}
3743

@@ -47,10 +53,26 @@ export function QuestionEditor({ q, qi, onChange, onRemove, canRemove }: Props)
4753

4854
function removeOption(oi: number) {
4955
const opts = (q.options ?? []).filter((_, i) => i !== oi);
50-
const newCorrect =
51-
q.correctIndex >= oi && q.correctIndex > 0 ? q.correctIndex - 1 : q.correctIndex;
56+
if (type === 'multi_select') {
57+
const newIndices = (q.correctIndices ?? [])
58+
.filter((i) => i !== oi)
59+
.map((i) => (i > oi ? i - 1 : i));
60+
onChange('correctIndices', newIndices);
61+
} else {
62+
const newCorrect =
63+
q.correctIndex >= oi && q.correctIndex > 0 ? q.correctIndex - 1 : q.correctIndex;
64+
onChange('correctIndex', Math.min(newCorrect, opts.length - 1));
65+
}
5266
onChange('options', opts);
53-
onChange('correctIndex', Math.min(newCorrect, opts.length - 1));
67+
}
68+
69+
function toggleCorrectIndex(oi: number) {
70+
const current = q.correctIndices ?? [];
71+
if (current.includes(oi)) {
72+
onChange('correctIndices', current.filter((i) => i !== oi));
73+
} else {
74+
onChange('correctIndices', [...current, oi]);
75+
}
5476
}
5577

5678
return (
@@ -105,20 +127,24 @@ export function QuestionEditor({ q, qi, onChange, onRemove, canRemove }: Props)
105127
<div className="form-group">
106128
<p className="form-label">Question Type</p>
107129
<div className="flex gap-2" style={{ flexWrap: 'wrap' }}>
108-
{(['multiple_choice', 'true_false', 'open_text'] as QuestionType[]).map((t) => (
109-
<button
110-
type="button"
111-
key={t}
112-
onClick={() => setType(t)}
113-
className={`btn btn-sm ${type === t ? 'btn-primary' : 'btn-ghost'}`}
114-
>
115-
{t === 'multiple_choice'
116-
? 'Multiple Choice'
117-
: t === 'true_false'
118-
? 'True / False'
119-
: 'Open Text'}
120-
</button>
121-
))}
130+
{(['multiple_choice', 'multi_select', 'true_false', 'open_text'] as QuestionType[]).map(
131+
(t) => (
132+
<button
133+
type="button"
134+
key={t}
135+
onClick={() => setType(t)}
136+
className={`btn btn-sm ${type === t ? 'btn-primary' : 'btn-ghost'}`}
137+
>
138+
{t === 'multiple_choice'
139+
? 'Single Choice'
140+
: t === 'multi_select'
141+
? 'Multiple Answers'
142+
: t === 'true_false'
143+
? 'True / False'
144+
: 'Open Text'}
145+
</button>
146+
),
147+
)}
122148
</div>
123149
</div>
124150

@@ -153,7 +179,7 @@ export function QuestionEditor({ q, qi, onChange, onRemove, canRemove }: Props)
153179
/>
154180
)}
155181

156-
{/* Options */}
182+
{/* Options for single choice */}
157183
{type === 'multiple_choice' && (
158184
<div className="mb-3">
159185
<p
@@ -216,6 +242,80 @@ export function QuestionEditor({ q, qi, onChange, onRemove, canRemove }: Props)
216242
</div>
217243
)}
218244

245+
{/* Options for multi select */}
246+
{type === 'multi_select' && (
247+
<div className="mb-3">
248+
<p
249+
style={{
250+
display: 'block',
251+
fontSize: '0.82rem',
252+
color: 'var(--text2)',
253+
marginBottom: 4,
254+
fontWeight: 500,
255+
}}
256+
>
257+
Answer Options{' '}
258+
<span style={{ fontWeight: 400, color: 'var(--text3)' }}>
259+
— check all correct answers
260+
</span>
261+
</p>
262+
{(q.options ?? []).map((opt, oi) => {
263+
const isChecked = (q.correctIndices ?? []).includes(oi);
264+
return (
265+
<div key={String.fromCharCode(65 + oi)} className="flex items-center gap-2 mb-2">
266+
<button
267+
type="button"
268+
onClick={() => toggleCorrectIndex(oi)}
269+
title={isChecked ? 'Mark as incorrect' : 'Mark as correct'}
270+
style={{
271+
width: 28,
272+
height: 28,
273+
borderRadius: 6,
274+
background: isChecked ? 'var(--success)' : 'var(--border)',
275+
border: 'none',
276+
display: 'flex',
277+
alignItems: 'center',
278+
justifyContent: 'center',
279+
fontWeight: 800,
280+
fontSize: '0.75rem',
281+
flexShrink: 0,
282+
color: isChecked ? '#fff' : 'var(--text2)',
283+
cursor: 'pointer',
284+
}}
285+
>
286+
{isChecked ? '✓' : String.fromCharCode(65 + oi)}
287+
</button>
288+
<Input
289+
style={{ flex: 1, marginBottom: 0 }}
290+
value={opt}
291+
onChange={(e) => updateOption(oi, e.target.value)}
292+
placeholder={`Option ${String.fromCharCode(65 + oi)}`}
293+
/>
294+
{(q.options ?? []).length > 2 && (
295+
<button
296+
type="button"
297+
onClick={() => removeOption(oi)}
298+
className="btn-icon"
299+
style={{ flexShrink: 0 }}
300+
title="Remove option"
301+
>
302+
303+
</button>
304+
)}
305+
</div>
306+
);
307+
})}
308+
<button
309+
type="button"
310+
onClick={addOption}
311+
className="btn btn-ghost btn-sm mt-1"
312+
style={{ fontSize: '0.8rem' }}
313+
>
314+
+ Add Option
315+
</button>
316+
</div>
317+
)}
318+
219319
{type === 'true_false' && (
220320
<div className="mb-3">
221321
<p

client/src/pages/play/Game.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export default function Game() {
2626
const [phase, setPhase] = useState<Phase>('waiting');
2727
const [question, setQuestion] = useState<QuestionPayload | null>(null);
2828
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
29+
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
30+
const [multiSelectSubmitted, setMultiSelectSubmitted] = useState(false);
2931
const [openTextInput, setOpenTextInput] = useState('');
3032
const [openTextSubmitted, setOpenTextSubmitted] = useState(false);
3133
const [answerResult, setAnswerResult] = useState<{
@@ -112,6 +114,8 @@ export default function Game() {
112114
useSocketEvent<QuestionPayload>('game:question', (data) => {
113115
setQuestion(data);
114116
setSelectedIndex(null);
117+
setSelectedIndices([]);
118+
setMultiSelectSubmitted(false);
115119
setOpenTextInput('');
116120
setOpenTextSubmitted(false);
117121
setAnswerResult(null);
@@ -202,6 +206,25 @@ export default function Game() {
202206
});
203207
}
204208

209+
function toggleMultiSelectIndex(index: number) {
210+
if (multiSelectSubmitted) return;
211+
setSelectedIndices((prev) =>
212+
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index],
213+
);
214+
}
215+
216+
function submitMultiSelect() {
217+
if (!question || phase !== 'question' || multiSelectSubmitted) return;
218+
setMultiSelectSubmitted(true);
219+
socket.emit('player:answer', {
220+
sessionId: Number(sessionId),
221+
questionId: question.questionId,
222+
chosenIndex: -3,
223+
chosenIndices: selectedIndices,
224+
playerId,
225+
});
226+
}
227+
205228
if (phase === 'waiting') return <WaitingScreen username={username} avatar={myAvatar} />;
206229

207230
if (phase === 'countdown') return <CountdownScreen seconds={countdownSec} />;
@@ -212,6 +235,8 @@ export default function Game() {
212235
question={question}
213236
timeLeft={timeLeft}
214237
selectedIndex={selectedIndex}
238+
selectedIndices={selectedIndices}
239+
multiSelectSubmitted={multiSelectSubmitted}
215240
openTextInput={openTextInput}
216241
openTextSubmitted={openTextSubmitted}
217242
eliminatedIndices={eliminatedIndices}
@@ -220,6 +245,8 @@ export default function Game() {
220245
jokersEnabled={jokersEnabled}
221246
jokersUsed={jokersUsed}
222247
onAnswer={submitAnswer}
248+
onToggleMultiSelect={toggleMultiSelectIndex}
249+
onMultiSelectSubmit={submitMultiSelect}
223250
onOpenTextChange={setOpenTextInput}
224251
onOpenTextSubmit={submitOpenText}
225252
onPassJoker={() => {

0 commit comments

Comments
 (0)