question-bank-framework 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,13 @@
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
4
  import { Link } from '@tanstack/react-router';
5
- import { Trash2 } from 'lucide-react';
5
+ import { ListOrdered, Trash2 } from 'lucide-react';
6
6
  import { useBank } from '../../core/config.tsx';
7
7
  import type { QuizSession } from '../../core/types.ts';
8
+ import { isAnswerProvided, scoreAnswers } from '../../core/types.ts';
8
9
  import { Button } from './ui/button';
9
- import { deleteQuiz, listQuizzes } from '../lib/quiz-storage.ts';
10
+ import { QuizQuestionEditor } from './quiz-question-editor';
11
+ import { deleteQuiz, listQuizzes, setQuizQuestions } from '../lib/quiz-storage.ts';
10
12
 
11
13
  export function QuizList() {
12
14
  const bank = useBank();
@@ -14,6 +16,7 @@ export function QuizList() {
14
16
  const [loading, setLoading] = useState(true);
15
17
  const [deletingId, setDeletingId] = useState<string | null>(null);
16
18
  const [deleteError, setDeleteError] = useState(false);
19
+ const [editingQuiz, setEditingQuiz] = useState<QuizSession | null>(null);
17
20
 
18
21
  useEffect(() => {
19
22
  let active = true;
@@ -40,6 +43,11 @@ export function QuizList() {
40
43
  }
41
44
  };
42
45
 
46
+ const saveQuestions = async (quiz: QuizSession, questionIds: string[]) => {
47
+ const updated = await setQuizQuestions(bank.namespace, quiz, questionIds);
48
+ setQuizzes((current) => current.map((item) => (item.id === updated.id ? updated : item)));
49
+ };
50
+
43
51
  return (
44
52
  <div className="grid gap-8">
45
53
  <div className="flex flex-wrap items-end justify-between gap-4">
@@ -63,7 +71,13 @@ export function QuizList() {
63
71
  )}
64
72
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
65
73
  {quizzes.map((quiz) => {
66
- const answeredCount = Object.values(quiz.answers).filter((answer) => answer.trim().length > 0).length;
74
+ const questions = quiz.questionIds
75
+ .map((id) => bank.questionById.get(id))
76
+ .filter((question) => question !== undefined);
77
+ const answeredCount = questions.filter((question) =>
78
+ isAnswerProvided(question, quiz.answers[question.id]),
79
+ ).length;
80
+ const score = scoreAnswers(questions, quiz.answers);
67
81
  return (
68
82
  <div key={quiz.id} className="group relative">
69
83
  <Link
@@ -71,26 +85,40 @@ export function QuizList() {
71
85
  params={{ quizId: quiz.id }}
72
86
  className="block rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"
73
87
  >
74
- <h2 className="m-0 pr-10 text-lg font-semibold text-gray-900">{quiz.title}</h2>
88
+ <h2 className="m-0 pr-16 text-lg font-semibold text-gray-900">{quiz.title}</h2>
75
89
  <p className="mt-2 text-sm text-gray-700">
76
90
  {quiz.questionIds.length} {quiz.questionIds.length === 1 ? 'question' : 'questions'}
77
91
  </p>
78
92
  <p className="mt-1 text-sm text-gray-600">
79
93
  {answeredCount} of {quiz.questionIds.length} answered
94
+ {score.gradeable > 0 && ` • ${score.correct}/${score.gradeable} correct`}
80
95
  </p>
81
96
  </Link>
82
- <Button
83
- type="button"
84
- variant="outline"
85
- size="icon-xs"
86
- aria-label={`Delete ${quiz.title}`}
87
- title="Delete quiz"
88
- disabled={deletingId === quiz.id}
89
- onClick={() => removeQuiz(quiz)}
90
- className="absolute right-3 top-3 rounded-full border-red-200 bg-white text-red-600 opacity-0 shadow-sm transition-opacity hover:border-red-300 hover:bg-red-50 hover:text-red-700 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
91
- >
92
- <Trash2 aria-hidden="true" />
93
- </Button>
97
+ <div className="absolute right-3 top-3 flex gap-1.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100">
98
+ <Button
99
+ type="button"
100
+ variant="outline"
101
+ size="icon-xs"
102
+ aria-label={`Reorder questions in ${quiz.title}`}
103
+ title="Reorder or remove questions"
104
+ onClick={() => setEditingQuiz(quiz)}
105
+ className="rounded-full bg-white shadow-sm"
106
+ >
107
+ <ListOrdered aria-hidden="true" />
108
+ </Button>
109
+ <Button
110
+ type="button"
111
+ variant="outline"
112
+ size="icon-xs"
113
+ aria-label={`Delete ${quiz.title}`}
114
+ title="Delete quiz"
115
+ disabled={deletingId === quiz.id}
116
+ onClick={() => removeQuiz(quiz)}
117
+ className="rounded-full border-red-200 bg-white text-red-600 shadow-sm hover:border-red-300 hover:bg-red-50 hover:text-red-700"
118
+ >
119
+ <Trash2 aria-hidden="true" />
120
+ </Button>
121
+ </div>
94
122
  </div>
95
123
  );
96
124
  })}
@@ -105,6 +133,14 @@ export function QuizList() {
105
133
  </Button>
106
134
  </div>
107
135
  )}
136
+
137
+ <QuizQuestionEditor
138
+ quiz={editingQuiz}
139
+ onOpenChange={(open) => {
140
+ if (!open) setEditingQuiz(null);
141
+ }}
142
+ onSave={saveQuestions}
143
+ />
108
144
  </div>
109
145
  );
110
146
  }
@@ -4,13 +4,26 @@ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, useParams } from '@tanstack/react-router';
5
5
  import { Check, Pencil, X } from 'lucide-react';
6
6
  import { useBank } from '../../core/config.tsx';
7
- import type { QuestionStatus, QuizSession } from '../../core/types.ts';
7
+ import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
8
+ import {
9
+ getCorrectIds,
10
+ isAnswerCorrect,
11
+ isAnswerProvided,
12
+ isChoiceQuestion,
13
+ questionKind,
14
+ questionKindLabels,
15
+ scoreAnswers,
16
+ toSelectedIds,
17
+ } from '../../core/types.ts';
8
18
  import { Badge } from './ui/badge';
9
19
  import { Button } from './ui/button';
10
20
  import { Input } from './ui/input';
11
21
  import { MarkdownContent } from './markdown-content';
12
22
  import { setQuestionStatus } from '../lib/question-status-storage.ts';
13
23
  import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
24
+ import { cn } from '../lib/utils.ts';
25
+
26
+ const optionLetter = (index: number): string => String.fromCharCode(65 + index);
14
27
 
15
28
  export function QuizPage() {
16
29
  const bank = useBank();
@@ -19,6 +32,7 @@ export function QuizPage() {
19
32
  const [loading, setLoading] = useState(true);
20
33
  const [saving, setSaving] = useState(false);
21
34
  const [shownAnswerId, setShownAnswerId] = useState<string | null>(null);
35
+ const [checked, setChecked] = useState<Record<string, boolean>>({});
22
36
  const [editingTitle, setEditingTitle] = useState(false);
23
37
  const [titleDraft, setTitleDraft] = useState('');
24
38
  const titleInputRef = useRef<HTMLInputElement>(null);
@@ -33,6 +47,7 @@ export function QuizPage() {
33
47
  useEffect(() => {
34
48
  let active = true;
35
49
  setShownAnswerId(null);
50
+ setChecked({});
36
51
  getQuiz(bank.namespace, quizId).then((quiz) => {
37
52
  if (active) setSession(quiz);
38
53
  }).finally(() => {
@@ -72,13 +87,39 @@ export function QuizPage() {
72
87
  setSession((current) => (current ? { ...current, currentQuestion: index, completed: false } : current));
73
88
  };
74
89
 
75
- const updateAnswer = (value: string) => {
90
+ const updateWrittenAnswer = (value: string) => {
76
91
  if (!activeQuestion) return;
77
92
  setSession((current) =>
78
93
  current ? { ...current, completed: false, answers: { ...current.answers, [activeQuestion.id]: value } } : current,
79
94
  );
80
95
  };
81
96
 
97
+ const setChoiceAnswer = (questionId: string, value: string[]) => {
98
+ setChecked((current) => (current[questionId] ? { ...current, [questionId]: false } : current));
99
+ setSession((current) =>
100
+ current ? { ...current, completed: false, answers: { ...current.answers, [questionId]: value } } : current,
101
+ );
102
+ };
103
+
104
+ const toggleOption = (questionId: string, optionId: string, single: boolean) => {
105
+ if (!activeQuestion) return;
106
+ const current = toSelectedIds(activeQuestion, session?.answers[questionId]);
107
+ if (single) {
108
+ setChoiceAnswer(questionId, current[0] === optionId ? [] : [optionId]);
109
+ } else {
110
+ setChoiceAnswer(
111
+ questionId,
112
+ current.includes(optionId) ? current.filter((id) => id !== optionId) : [...current, optionId],
113
+ );
114
+ }
115
+ };
116
+
117
+ const checkAnswer = () => {
118
+ if (!activeQuestion) return;
119
+ setChecked((current) => ({ ...current, [activeQuestion.id]: true }));
120
+ if (bank.answers[activeQuestion.id]) setShownAnswerId(activeQuestion.id);
121
+ };
122
+
82
123
  const updateQuestionStatus = (status: QuestionStatus) => {
83
124
  if (!activeQuestion) return;
84
125
  void setQuestionStatus(bank.namespace, activeQuestion.id, status);
@@ -121,9 +162,17 @@ export function QuizPage() {
121
162
  );
122
163
  }
123
164
 
124
- const answer = session.answers[activeQuestion.id] ?? '';
165
+ const answer: QuizAnswer | undefined = session.answers[activeQuestion.id];
125
166
  const suggestedAnswer = bank.answers[activeQuestion.id];
126
167
  const isSuggestedAnswerShown = shownAnswerId === activeQuestion.id;
168
+ const choice = isChoiceQuestion(activeQuestion);
169
+ const kind = questionKind(activeQuestion);
170
+ const selectedIds = toSelectedIds(activeQuestion, answer);
171
+ const hasSelection = isAnswerProvided(activeQuestion, answer);
172
+ const isChecked = checked[activeQuestion.id] === true;
173
+ const correctness = isChecked ? isAnswerCorrect(activeQuestion, answer) : null;
174
+ const correctIds = getCorrectIds(activeQuestion);
175
+ const score = scoreAnswers(quizQuestions, session.answers);
127
176
 
128
177
  return (
129
178
  <div className="mx-auto grid max-w-3xl gap-6">
@@ -168,12 +217,66 @@ export function QuizPage() {
168
217
  <span className="text-sm text-gray-600">{saving ? 'Saving...' : 'Saved'}</span>
169
218
  </div>
170
219
 
171
- <p className="text-base font-semibold text-gray-900">
172
- Question {activeIndex + 1} of {quizQuestions.length}
173
- </p>
220
+ <div className="flex flex-wrap items-center gap-3">
221
+ <p className="m-0 text-base font-semibold text-gray-900">
222
+ Question {activeIndex + 1} of {quizQuestions.length}
223
+ </p>
224
+ {score.gradeable > 0 && (
225
+ <Badge variant="secondary" aria-label={`Quiz score: ${score.correct} of ${score.gradeable} correct`}>
226
+ {score.correct}/{score.gradeable} correct
227
+ </Badge>
228
+ )}
229
+ {session.completed && (
230
+ <Badge variant="outline" className="border-green-300 bg-green-50 text-green-800">
231
+ Quiz complete
232
+ </Badge>
233
+ )}
234
+ </div>
235
+
236
+ {session.completed && score.gradeable > 0 && (
237
+ <section aria-label="Quiz results" className="rounded-xl border border-gray-200 bg-gray-50 p-5">
238
+ <h2 className="m-0 text-lg font-semibold text-gray-900">
239
+ You scored {score.correct} of {score.gradeable}
240
+ </h2>
241
+ <ol className="mt-3 grid gap-1.5">
242
+ {quizQuestions.map((question, index) => {
243
+ const result = isAnswerCorrect(question, session.answers[question.id]);
244
+ return (
245
+ <li key={question.id}>
246
+ <button
247
+ type="button"
248
+ onClick={() => goToQuestion(index)}
249
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-gray-100"
250
+ aria-label={`Review question ${index + 1}: ${question.title} — ${result === null ? 'written' : result ? 'correct' : 'incorrect'}`}
251
+ >
252
+ <span
253
+ aria-hidden="true"
254
+ className={cn(
255
+ 'flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold',
256
+ result === true && 'bg-green-600 text-white',
257
+ result === false && 'bg-red-600 text-white',
258
+ result === null && 'bg-gray-200 text-gray-700',
259
+ )}
260
+ >
261
+ {result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
262
+ </span>
263
+ <span className="min-w-0 flex-1 truncate text-gray-900">{question.title}</span>
264
+ <span className="shrink-0 text-xs text-gray-500">
265
+ {result === null ? 'Written' : result ? 'Correct' : 'Incorrect'}
266
+ </span>
267
+ </button>
268
+ </li>
269
+ );
270
+ })}
271
+ </ol>
272
+ </section>
273
+ )}
174
274
 
175
275
  <section className="rounded-xl border border-gray-200 bg-white p-5">
176
- <h2 className="m-0 text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
276
+ <div className="flex flex-wrap items-center gap-2">
277
+ <h2 className="m-0 flex-1 text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
278
+ <Badge variant="outline">{questionKindLabels[kind]}</Badge>
279
+ </div>
177
280
  <p className="mt-1 font-mono text-xs text-gray-500">{activeQuestion.id}</p>
178
281
  {activeQuestion.tags.length > 0 && (
179
282
  <div className="mt-2 flex flex-wrap gap-1">
@@ -186,32 +289,134 @@ export function QuizPage() {
186
289
  )}
187
290
  <MarkdownContent content={activeQuestion.prompt} className="mt-4 text-base leading-relaxed text-gray-800" />
188
291
 
189
- <div className="mt-6">
190
- <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
191
- Your answer
192
- </label>
193
- <textarea
194
- id="quiz-answer"
195
- value={answer}
196
- onChange={(event) => updateAnswer(event.target.value)}
197
- placeholder="Write your working or notes here..."
198
- className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
199
- />
200
- </div>
292
+ {choice ? (
293
+ <div className="mt-6">
294
+ <p id="quiz-choices-label" className="text-sm font-semibold text-gray-900">
295
+ {kind === 'single' ? 'Choose one answer' : 'Choose all that apply'}
296
+ </p>
297
+ <div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid gap-2">
298
+ {(activeQuestion.options ?? []).map((option, index) => {
299
+ const selected = selectedIds.includes(option.id);
300
+ const isCorrectOption = correctIds.includes(option.id);
301
+ const showCorrectness = isChecked;
302
+ return (
303
+ <label
304
+ key={option.id}
305
+ className={cn(
306
+ 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
307
+ !showCorrectness && selected && 'border-gray-900 bg-gray-50',
308
+ !showCorrectness && !selected && 'border-gray-300 bg-white hover:border-gray-500',
309
+ showCorrectness && isCorrectOption && 'border-green-600 bg-green-50',
310
+ showCorrectness && !isCorrectOption && selected && 'border-red-600 bg-red-50',
311
+ showCorrectness && !isCorrectOption && !selected && 'border-gray-200 bg-white opacity-70',
312
+ )}
313
+ >
314
+ <input
315
+ type={kind === 'single' ? 'radio' : 'checkbox'}
316
+ name={`quiz-choice-${activeQuestion.id}`}
317
+ checked={selected}
318
+ onChange={() => toggleOption(activeQuestion.id, option.id, kind === 'single')}
319
+ className="mt-1 size-4 shrink-0 accent-gray-900"
320
+ aria-label={`Option ${optionLetter(index)}`}
321
+ />
322
+ <span
323
+ aria-hidden="true"
324
+ className={cn(
325
+ 'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold',
326
+ showCorrectness && isCorrectOption
327
+ ? 'border-green-600 bg-green-600 text-white'
328
+ : showCorrectness && selected
329
+ ? 'border-red-600 bg-red-600 text-white'
330
+ : selected
331
+ ? 'border-gray-900 bg-gray-900 text-white'
332
+ : 'border-gray-300 bg-white text-gray-600',
333
+ )}
334
+ >
335
+ {optionLetter(index)}
336
+ </span>
337
+ <MarkdownContent content={option.text} className="min-w-0 flex-1 text-base leading-7" />
338
+ {showCorrectness && isCorrectOption && (
339
+ <Badge variant="outline" className="shrink-0 border-green-600 bg-white text-green-800">
340
+ Correct
341
+ </Badge>
342
+ )}
343
+ {showCorrectness && !isCorrectOption && selected && (
344
+ <Badge variant="outline" className="shrink-0 border-red-600 bg-white text-red-800">
345
+ Yours
346
+ </Badge>
347
+ )}
348
+ </label>
349
+ );
350
+ })}
351
+ </div>
201
352
 
202
- {suggestedAnswer && (
353
+ {isChecked && correctness !== null && (
354
+ <div
355
+ role="status"
356
+ className={cn(
357
+ 'mt-4 rounded-lg border p-4 text-sm font-medium',
358
+ correctness ? 'border-green-300 bg-green-50 text-green-900' : 'border-red-300 bg-red-50 text-red-900',
359
+ )}
360
+ >
361
+ {correctness
362
+ ? 'Correct — nice work.'
363
+ : `Not quite. Correct answer${correctIds.length > 1 ? 's' : ''}: ${correctIds
364
+ .map((id) => {
365
+ const index = (activeQuestion.options ?? []).findIndex((o) => o.id === id);
366
+ return index >= 0 ? optionLetter(index) : id;
367
+ })
368
+ .join(', ')}`}
369
+ </div>
370
+ )}
371
+
372
+ <div className="mt-4 flex flex-wrap gap-2">
373
+ <Button onClick={checkAnswer} disabled={!hasSelection} variant={isChecked ? 'outline' : 'default'}>
374
+ {isChecked ? 'Checked' : 'Check answer'}
375
+ </Button>
376
+ {suggestedAnswer && (
377
+ <Button
378
+ variant="outline"
379
+ aria-expanded={isSuggestedAnswerShown}
380
+ aria-controls="suggested-answer"
381
+ onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
382
+ >
383
+ {isSuggestedAnswerShown ? 'Hide explanation' : 'Show explanation'}
384
+ </Button>
385
+ )}
386
+ </div>
387
+ </div>
388
+ ) : (
389
+ <div className="mt-6">
390
+ <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
391
+ Your answer
392
+ </label>
393
+ <textarea
394
+ id="quiz-answer"
395
+ value={typeof answer === 'string' ? answer : ''}
396
+ onChange={(event) => updateWrittenAnswer(event.target.value)}
397
+ placeholder="Write your working or notes here..."
398
+ className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
399
+ />
400
+ </div>
401
+ )}
402
+
403
+ {suggestedAnswer && (choice ? isSuggestedAnswerShown : true) && (
203
404
  <div className="mt-5">
204
- <Button
205
- variant="outline"
206
- aria-expanded={isSuggestedAnswerShown}
207
- aria-controls="suggested-answer"
208
- onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
209
- >
210
- {isSuggestedAnswerShown ? 'Hide answer' : 'Show answer'}
211
- </Button>
405
+ {!choice && (
406
+ <Button
407
+ variant="outline"
408
+ aria-expanded={isSuggestedAnswerShown}
409
+ aria-controls="suggested-answer"
410
+ onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
411
+ >
412
+ {isSuggestedAnswerShown ? 'Hide answer' : 'Show answer'}
413
+ </Button>
414
+ )}
212
415
  {isSuggestedAnswerShown && (
213
416
  <section id="suggested-answer" className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
214
- <h3 className="m-0 text-base font-semibold text-gray-900">Suggested answer</h3>
417
+ <h3 className="m-0 text-base font-semibold text-gray-900">
418
+ {choice ? 'Explanation' : 'Suggested answer'}
419
+ </h3>
215
420
  <MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
216
421
  </section>
217
422
  )}
@@ -219,9 +424,18 @@ export function QuizPage() {
219
424
  )}
220
425
 
221
426
  <div className="mt-6 flex flex-wrap items-center justify-between gap-3">
222
- <Button variant="outline" onClick={() => goToQuestion(Math.max(activeIndex - 1, 0))} disabled={activeIndex === 0}>
223
- Previous
224
- </Button>
427
+ <div className="flex gap-2">
428
+ <Button variant="outline" onClick={() => goToQuestion(Math.max(activeIndex - 1, 0))} disabled={activeIndex === 0}>
429
+ Previous
430
+ </Button>
431
+ <Button
432
+ variant="outline"
433
+ onClick={() => goToQuestion(Math.min(activeIndex + 1, quizQuestions.length - 1))}
434
+ disabled={activeIndex === quizQuestions.length - 1}
435
+ >
436
+ Next
437
+ </Button>
438
+ </div>
225
439
  <div className="ml-auto flex flex-wrap items-center justify-end gap-2">
226
440
  <Button variant="outline" onClick={() => updateQuestionStatus('unanswered')} disabled={session.completed}>
227
441
  Skip
@@ -0,0 +1,171 @@
1
+ // Framework-owned: eject with `qb reveal framework/components/quiz-question-editor.tsx`.
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { ArrowDown, ArrowUp, GripVertical, Shuffle, Trash2 } from 'lucide-react';
5
+ import { useBank } from '../../core/config.tsx';
6
+ import type { QuizSession } from '../../core/types.ts';
7
+ import { Button } from './ui/button';
8
+ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from './ui/sheet';
9
+ import { cn } from '../lib/utils.ts';
10
+
11
+ const move = (ids: string[], from: number, to: number) => {
12
+ const next = [...ids];
13
+ const [item] = next.splice(from, 1);
14
+ if (item !== undefined) next.splice(to, 0, item);
15
+ return next;
16
+ };
17
+
18
+ export function QuizQuestionEditor({
19
+ quiz,
20
+ onOpenChange,
21
+ onSave,
22
+ }: {
23
+ quiz: QuizSession | null;
24
+ onOpenChange: (open: boolean) => void;
25
+ onSave: (quiz: QuizSession, questionIds: string[]) => Promise<void>;
26
+ }) {
27
+ const bank = useBank();
28
+ const [ids, setIds] = useState<string[]>([]);
29
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
30
+ const [saving, setSaving] = useState(false);
31
+ const [saveError, setSaveError] = useState(false);
32
+
33
+ useEffect(() => {
34
+ if (!quiz) return;
35
+ setIds(quiz.questionIds);
36
+ setSaveError(false);
37
+ }, [quiz]);
38
+
39
+ const changed = quiz !== null && (ids.length !== quiz.questionIds.length || ids.some((id, index) => id !== quiz.questionIds[index]));
40
+
41
+ const shuffle = () => {
42
+ setIds((current) => {
43
+ if (current.length < 2) return current;
44
+ const next = [...current];
45
+ for (let i = next.length - 1; i > 0; i--) {
46
+ const j = Math.floor(Math.random() * (i + 1));
47
+ [next[i], next[j]] = [next[j] as string, next[i] as string];
48
+ }
49
+ return next;
50
+ });
51
+ };
52
+
53
+ const save = async () => {
54
+ if (!quiz) return;
55
+ setSaving(true);
56
+ setSaveError(false);
57
+ try {
58
+ await onSave(quiz, ids);
59
+ onOpenChange(false);
60
+ } catch {
61
+ setSaveError(true);
62
+ } finally {
63
+ setSaving(false);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <Sheet open={quiz !== null} onOpenChange={onOpenChange}>
69
+ <SheetContent className="w-full gap-0 sm:max-w-md">
70
+ <SheetHeader className="border-b">
71
+ <SheetTitle>Edit questions</SheetTitle>
72
+ <SheetDescription>{quiz?.title}. Drag to reorder, or remove questions you no longer want.</SheetDescription>
73
+ <div className="mt-2 flex items-center justify-between gap-2">
74
+ <span className="text-sm text-muted-foreground">
75
+ {ids.length} {ids.length === 1 ? 'question' : 'questions'}
76
+ </span>
77
+ <Button
78
+ type="button"
79
+ variant="outline"
80
+ size="sm"
81
+ onClick={shuffle}
82
+ disabled={ids.length < 2}
83
+ aria-label="Shuffle question order"
84
+ title="Shuffle question order"
85
+ >
86
+ <Shuffle aria-hidden="true" />
87
+ Shuffle
88
+ </Button>
89
+ </div>
90
+ </SheetHeader>
91
+ <ol className="m-0 min-h-0 flex-1 list-none overflow-y-auto p-2">
92
+ {ids.map((id, index) => (
93
+ <li
94
+ key={id}
95
+ draggable
96
+ onDragStart={(event) => {
97
+ setDragIndex(index);
98
+ event.dataTransfer.effectAllowed = 'move';
99
+ }}
100
+ onDragOver={(event) => {
101
+ event.preventDefault();
102
+ if (dragIndex === null || dragIndex === index) return;
103
+ setIds((current) => move(current, dragIndex, index));
104
+ setDragIndex(index);
105
+ }}
106
+ onDragEnd={() => setDragIndex(null)}
107
+ className={cn(
108
+ 'flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-muted',
109
+ dragIndex === index && 'border-gray-300 bg-muted opacity-60',
110
+ )}
111
+ >
112
+ <GripVertical aria-hidden="true" className="size-4 shrink-0 cursor-grab text-muted-foreground" />
113
+ <span className="w-6 shrink-0 text-right text-sm tabular-nums text-muted-foreground">{index + 1}</span>
114
+ <span className="min-w-0 flex-1 truncate text-sm">{bank.questionById.get(id)?.title ?? id}</span>
115
+ <Button
116
+ type="button"
117
+ variant="ghost"
118
+ size="icon-xs"
119
+ aria-label="Move up"
120
+ disabled={index === 0}
121
+ onClick={() => setIds((current) => move(current, index, index - 1))}
122
+ >
123
+ <ArrowUp aria-hidden="true" />
124
+ </Button>
125
+ <Button
126
+ type="button"
127
+ variant="ghost"
128
+ size="icon-xs"
129
+ aria-label="Move down"
130
+ disabled={index === ids.length - 1}
131
+ onClick={() => setIds((current) => move(current, index, index + 1))}
132
+ >
133
+ <ArrowDown aria-hidden="true" />
134
+ </Button>
135
+ <Button
136
+ type="button"
137
+ variant="ghost"
138
+ size="icon-xs"
139
+ aria-label="Remove question"
140
+ className="text-red-600 hover:bg-red-50 hover:text-red-700"
141
+ onClick={() => setIds((current) => current.filter((item) => item !== id))}
142
+ >
143
+ <Trash2 aria-hidden="true" />
144
+ </Button>
145
+ </li>
146
+ ))}
147
+ {ids.length === 0 && (
148
+ <li className="px-2 py-8 text-center text-sm text-muted-foreground">
149
+ No questions left. Delete the quiz instead if you no longer need it.
150
+ </li>
151
+ )}
152
+ </ol>
153
+ <SheetFooter className="border-t">
154
+ {saveError && (
155
+ <p role="alert" className="m-0 text-sm text-red-700">
156
+ Could not save changes. Please try again.
157
+ </p>
158
+ )}
159
+ <div className="flex justify-end gap-2">
160
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
161
+ Cancel
162
+ </Button>
163
+ <Button type="button" onClick={save} disabled={!changed || ids.length === 0 || saving}>
164
+ {saving ? 'Saving...' : 'Save'}
165
+ </Button>
166
+ </div>
167
+ </SheetFooter>
168
+ </SheetContent>
169
+ </Sheet>
170
+ );
171
+ }
@@ -40,7 +40,7 @@ export const saveProgress = mutation({
40
40
  args: {
41
41
  quizId: v.id('quizzes'),
42
42
  title: v.string(),
43
- answers: v.record(v.string(), v.string()),
43
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
44
44
  currentQuestion: v.number(),
45
45
  completed: v.boolean(),
46
46
  },
@@ -48,3 +48,15 @@ export const saveProgress = mutation({
48
48
  await ctx.db.patch(quizId, { title, answers, currentQuestion, completed })
49
49
  },
50
50
  })
51
+
52
+ export const setQuestions = mutation({
53
+ args: {
54
+ quizId: v.id('quizzes'),
55
+ questionIds: v.array(v.string()),
56
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
57
+ currentQuestion: v.number(),
58
+ },
59
+ handler: async (ctx, { quizId, questionIds, answers, currentQuestion }) => {
60
+ await ctx.db.patch(quizId, { questionIds, answers, currentQuestion })
61
+ },
62
+ })