question-bank-framework 0.2.0 → 0.4.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,39 @@ 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, QuestionStatusMap, 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
- import { setQuestionStatus } from '../lib/question-status-storage.ts';
22
+ import { setQuestionStatus, getQuestionStatuses } from '../lib/question-status-storage.ts';
23
+ import { questionStatusLabels } from '../lib/question-filter.ts';
13
24
  import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
25
+ import { cn } from '../lib/utils.ts';
26
+
27
+ const optionLetter = (index: number): string => String.fromCharCode(65 + index);
28
+
29
+ const questionStatusStyles: Record<QuestionStatus, string> = {
30
+ unanswered: 'border-gray-300 bg-gray-50 text-gray-700',
31
+ revisit: 'border-amber-300 bg-amber-50 text-amber-900',
32
+ complete: 'border-green-300 bg-green-50 text-green-800',
33
+ };
34
+
35
+ const questionStatusDotStyles: Record<QuestionStatus, string> = {
36
+ unanswered: 'border-gray-300 bg-white text-gray-600 hover:border-gray-500',
37
+ revisit: 'border-amber-400 bg-amber-100 text-amber-900 hover:border-amber-600',
38
+ complete: 'border-green-500 bg-green-100 text-green-900 hover:border-green-700',
39
+ };
14
40
 
15
41
  export function QuizPage() {
16
42
  const bank = useBank();
@@ -19,8 +45,10 @@ export function QuizPage() {
19
45
  const [loading, setLoading] = useState(true);
20
46
  const [saving, setSaving] = useState(false);
21
47
  const [shownAnswerId, setShownAnswerId] = useState<string | null>(null);
48
+ const [checked, setChecked] = useState<Record<string, boolean>>({});
22
49
  const [editingTitle, setEditingTitle] = useState(false);
23
50
  const [titleDraft, setTitleDraft] = useState('');
51
+ const [statusMap, setStatusMap] = useState<QuestionStatusMap>({});
24
52
  const titleInputRef = useRef<HTMLInputElement>(null);
25
53
  const quizQuestions = useMemo(
26
54
  () =>
@@ -33,11 +61,15 @@ export function QuizPage() {
33
61
  useEffect(() => {
34
62
  let active = true;
35
63
  setShownAnswerId(null);
64
+ setChecked({});
36
65
  getQuiz(bank.namespace, quizId).then((quiz) => {
37
66
  if (active) setSession(quiz);
38
67
  }).finally(() => {
39
68
  if (active) setLoading(false);
40
69
  });
70
+ getQuestionStatuses(bank.namespace).then((statuses) => {
71
+ if (active) setStatusMap(statuses);
72
+ });
41
73
  return () => {
42
74
  active = false;
43
75
  };
@@ -72,16 +104,43 @@ export function QuizPage() {
72
104
  setSession((current) => (current ? { ...current, currentQuestion: index, completed: false } : current));
73
105
  };
74
106
 
75
- const updateAnswer = (value: string) => {
107
+ const updateWrittenAnswer = (value: string) => {
76
108
  if (!activeQuestion) return;
77
109
  setSession((current) =>
78
110
  current ? { ...current, completed: false, answers: { ...current.answers, [activeQuestion.id]: value } } : current,
79
111
  );
80
112
  };
81
113
 
114
+ const setChoiceAnswer = (questionId: string, value: string[]) => {
115
+ setChecked((current) => (current[questionId] ? { ...current, [questionId]: false } : current));
116
+ setSession((current) =>
117
+ current ? { ...current, completed: false, answers: { ...current.answers, [questionId]: value } } : current,
118
+ );
119
+ };
120
+
121
+ const toggleOption = (questionId: string, optionId: string, single: boolean) => {
122
+ if (!activeQuestion) return;
123
+ const current = toSelectedIds(activeQuestion, session?.answers[questionId]);
124
+ if (single) {
125
+ setChoiceAnswer(questionId, current[0] === optionId ? [] : [optionId]);
126
+ } else {
127
+ setChoiceAnswer(
128
+ questionId,
129
+ current.includes(optionId) ? current.filter((id) => id !== optionId) : [...current, optionId],
130
+ );
131
+ }
132
+ };
133
+
134
+ const checkAnswer = () => {
135
+ if (!activeQuestion) return;
136
+ setChecked((current) => ({ ...current, [activeQuestion.id]: true }));
137
+ if (bank.answers[activeQuestion.id]) setShownAnswerId(activeQuestion.id);
138
+ };
139
+
82
140
  const updateQuestionStatus = (status: QuestionStatus) => {
83
141
  if (!activeQuestion) return;
84
142
  void setQuestionStatus(bank.namespace, activeQuestion.id, status);
143
+ setStatusMap((current) => ({ ...current, [activeQuestion.id]: status }));
85
144
  setShownAnswerId(null);
86
145
  setSession((current) =>
87
146
  current
@@ -121,9 +180,18 @@ export function QuizPage() {
121
180
  );
122
181
  }
123
182
 
124
- const answer = session.answers[activeQuestion.id] ?? '';
183
+ const answer: QuizAnswer | undefined = session.answers[activeQuestion.id];
125
184
  const suggestedAnswer = bank.answers[activeQuestion.id];
126
185
  const isSuggestedAnswerShown = shownAnswerId === activeQuestion.id;
186
+ const choice = isChoiceQuestion(activeQuestion);
187
+ const kind = questionKind(activeQuestion);
188
+ const selectedIds = toSelectedIds(activeQuestion, answer);
189
+ const hasSelection = isAnswerProvided(activeQuestion, answer);
190
+ const isChecked = checked[activeQuestion.id] === true;
191
+ const correctness = isChecked ? isAnswerCorrect(activeQuestion, answer) : null;
192
+ const correctIds = getCorrectIds(activeQuestion);
193
+ const activeStatus: QuestionStatus = statusMap[activeQuestion.id] ?? 'unanswered';
194
+ const score = scoreAnswers(quizQuestions, session.answers);
127
195
 
128
196
  return (
129
197
  <div className="mx-auto grid max-w-3xl gap-6">
@@ -168,12 +236,98 @@ export function QuizPage() {
168
236
  <span className="text-sm text-gray-600">{saving ? 'Saving...' : 'Saved'}</span>
169
237
  </div>
170
238
 
171
- <p className="text-base font-semibold text-gray-900">
172
- Question {activeIndex + 1} of {quizQuestions.length}
173
- </p>
239
+ <div className="flex flex-wrap items-center gap-3">
240
+ <p className="m-0 text-base font-semibold text-gray-900">
241
+ Question {activeIndex + 1} of {quizQuestions.length}
242
+ </p>
243
+ {score.gradeable > 0 && (
244
+ <Badge variant="secondary" aria-label={`Quiz score: ${score.correct} of ${score.gradeable} correct`}>
245
+ {score.correct}/{score.gradeable} correct
246
+ </Badge>
247
+ )}
248
+ {session.completed && (
249
+ <Badge variant="outline" className="border-green-300 bg-green-50 text-green-800">
250
+ Quiz complete
251
+ </Badge>
252
+ )}
253
+ </div>
254
+
255
+ {quizQuestions.length > 1 && (
256
+ <nav aria-label="Questions in this quiz">
257
+ <ol className="m-0 flex list-none flex-wrap gap-1.5 p-0">
258
+ {quizQuestions.map((question, index) => {
259
+ const status = statusMap[question.id] ?? 'unanswered';
260
+ const isActive = index === activeIndex;
261
+ return (
262
+ <li key={question.id}>
263
+ <button
264
+ type="button"
265
+ onClick={() => goToQuestion(index)}
266
+ aria-label={`Go to question ${index + 1}: ${question.title} — ${questionStatusLabels[status]}${isActive ? ' (current question)' : ''}`}
267
+ aria-current={isActive ? 'true' : undefined}
268
+ title={`${question.title} — ${questionStatusLabels[status]}`}
269
+ className={cn(
270
+ 'flex size-8 items-center justify-center rounded-md border text-xs font-semibold tabular-nums transition-colors',
271
+ questionStatusDotStyles[status],
272
+ isActive && 'ring-2 ring-gray-900 ring-offset-2',
273
+ )}
274
+ >
275
+ {index + 1}
276
+ </button>
277
+ </li>
278
+ );
279
+ })}
280
+ </ol>
281
+ </nav>
282
+ )}
283
+
284
+ {session.completed && score.gradeable > 0 && (
285
+ <section aria-label="Quiz results" className="rounded-xl border border-gray-200 bg-gray-50 p-5">
286
+ <h2 className="m-0 text-lg font-semibold text-gray-900">
287
+ You scored {score.correct} of {score.gradeable}
288
+ </h2>
289
+ <ol className="mt-3 grid gap-1.5">
290
+ {quizQuestions.map((question, index) => {
291
+ const result = isAnswerCorrect(question, session.answers[question.id]);
292
+ return (
293
+ <li key={question.id}>
294
+ <button
295
+ type="button"
296
+ onClick={() => goToQuestion(index)}
297
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-gray-100"
298
+ aria-label={`Review question ${index + 1}: ${question.title} — ${result === null ? 'written' : result ? 'correct' : 'incorrect'}`}
299
+ >
300
+ <span
301
+ aria-hidden="true"
302
+ className={cn(
303
+ 'flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold',
304
+ result === true && 'bg-green-600 text-white',
305
+ result === false && 'bg-red-600 text-white',
306
+ result === null && 'bg-gray-200 text-gray-700',
307
+ )}
308
+ >
309
+ {result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
310
+ </span>
311
+ <span className="min-w-0 flex-1 truncate text-gray-900">{question.title}</span>
312
+ <span className="shrink-0 text-xs text-gray-500">
313
+ {result === null ? 'Written' : result ? 'Correct' : 'Incorrect'}
314
+ </span>
315
+ </button>
316
+ </li>
317
+ );
318
+ })}
319
+ </ol>
320
+ </section>
321
+ )}
174
322
 
175
323
  <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>
324
+ <div className="flex flex-wrap items-center gap-2">
325
+ <h2 className="m-0 flex-1 text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
326
+ <Badge variant="outline">{questionKindLabels[kind]}</Badge>
327
+ <Badge variant="outline" className={questionStatusStyles[activeStatus]}>
328
+ {questionStatusLabels[activeStatus]}
329
+ </Badge>
330
+ </div>
177
331
  <p className="mt-1 font-mono text-xs text-gray-500">{activeQuestion.id}</p>
178
332
  {activeQuestion.tags.length > 0 && (
179
333
  <div className="mt-2 flex flex-wrap gap-1">
@@ -186,32 +340,134 @@ export function QuizPage() {
186
340
  )}
187
341
  <MarkdownContent content={activeQuestion.prompt} className="mt-4 text-base leading-relaxed text-gray-800" />
188
342
 
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>
343
+ {choice ? (
344
+ <div className="mt-6">
345
+ <p id="quiz-choices-label" className="text-sm font-semibold text-gray-900">
346
+ {kind === 'single' ? 'Choose one answer' : 'Choose all that apply'}
347
+ </p>
348
+ <div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid gap-2">
349
+ {(activeQuestion.options ?? []).map((option, index) => {
350
+ const selected = selectedIds.includes(option.id);
351
+ const isCorrectOption = correctIds.includes(option.id);
352
+ const showCorrectness = isChecked;
353
+ return (
354
+ <label
355
+ key={option.id}
356
+ className={cn(
357
+ 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
358
+ !showCorrectness && selected && 'border-gray-900 bg-gray-50',
359
+ !showCorrectness && !selected && 'border-gray-300 bg-white hover:border-gray-500',
360
+ showCorrectness && isCorrectOption && 'border-green-600 bg-green-50',
361
+ showCorrectness && !isCorrectOption && selected && 'border-red-600 bg-red-50',
362
+ showCorrectness && !isCorrectOption && !selected && 'border-gray-200 bg-white opacity-70',
363
+ )}
364
+ >
365
+ <input
366
+ type={kind === 'single' ? 'radio' : 'checkbox'}
367
+ name={`quiz-choice-${activeQuestion.id}`}
368
+ checked={selected}
369
+ onChange={() => toggleOption(activeQuestion.id, option.id, kind === 'single')}
370
+ className="mt-1 size-4 shrink-0 accent-gray-900"
371
+ aria-label={`Option ${optionLetter(index)}`}
372
+ />
373
+ <span
374
+ aria-hidden="true"
375
+ className={cn(
376
+ 'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold',
377
+ showCorrectness && isCorrectOption
378
+ ? 'border-green-600 bg-green-600 text-white'
379
+ : showCorrectness && selected
380
+ ? 'border-red-600 bg-red-600 text-white'
381
+ : selected
382
+ ? 'border-gray-900 bg-gray-900 text-white'
383
+ : 'border-gray-300 bg-white text-gray-600',
384
+ )}
385
+ >
386
+ {optionLetter(index)}
387
+ </span>
388
+ <MarkdownContent content={option.text} className="min-w-0 flex-1 text-base leading-7" />
389
+ {showCorrectness && isCorrectOption && (
390
+ <Badge variant="outline" className="shrink-0 border-green-600 bg-white text-green-800">
391
+ Correct
392
+ </Badge>
393
+ )}
394
+ {showCorrectness && !isCorrectOption && selected && (
395
+ <Badge variant="outline" className="shrink-0 border-red-600 bg-white text-red-800">
396
+ Yours
397
+ </Badge>
398
+ )}
399
+ </label>
400
+ );
401
+ })}
402
+ </div>
403
+
404
+ {isChecked && correctness !== null && (
405
+ <div
406
+ role="status"
407
+ className={cn(
408
+ 'mt-4 rounded-lg border p-4 text-sm font-medium',
409
+ correctness ? 'border-green-300 bg-green-50 text-green-900' : 'border-red-300 bg-red-50 text-red-900',
410
+ )}
411
+ >
412
+ {correctness
413
+ ? 'Correct — nice work.'
414
+ : `Not quite. Correct answer${correctIds.length > 1 ? 's' : ''}: ${correctIds
415
+ .map((id) => {
416
+ const index = (activeQuestion.options ?? []).findIndex((o) => o.id === id);
417
+ return index >= 0 ? optionLetter(index) : id;
418
+ })
419
+ .join(', ')}`}
420
+ </div>
421
+ )}
422
+
423
+ <div className="mt-4 flex flex-wrap gap-2">
424
+ <Button onClick={checkAnswer} disabled={!hasSelection} variant={isChecked ? 'outline' : 'default'}>
425
+ {isChecked ? 'Checked' : 'Check answer'}
426
+ </Button>
427
+ {suggestedAnswer && (
428
+ <Button
429
+ variant="outline"
430
+ aria-expanded={isSuggestedAnswerShown}
431
+ aria-controls="suggested-answer"
432
+ onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
433
+ >
434
+ {isSuggestedAnswerShown ? 'Hide explanation' : 'Show explanation'}
435
+ </Button>
436
+ )}
437
+ </div>
438
+ </div>
439
+ ) : (
440
+ <div className="mt-6">
441
+ <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
442
+ Your answer
443
+ </label>
444
+ <textarea
445
+ id="quiz-answer"
446
+ value={typeof answer === 'string' ? answer : ''}
447
+ onChange={(event) => updateWrittenAnswer(event.target.value)}
448
+ placeholder="Write your working or notes here..."
449
+ 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"
450
+ />
451
+ </div>
452
+ )}
201
453
 
202
- {suggestedAnswer && (
454
+ {suggestedAnswer && (choice ? isSuggestedAnswerShown : true) && (
203
455
  <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>
456
+ {!choice && (
457
+ <Button
458
+ variant="outline"
459
+ aria-expanded={isSuggestedAnswerShown}
460
+ aria-controls="suggested-answer"
461
+ onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
462
+ >
463
+ {isSuggestedAnswerShown ? 'Hide answer' : 'Show answer'}
464
+ </Button>
465
+ )}
212
466
  {isSuggestedAnswerShown && (
213
467
  <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>
468
+ <h3 className="m-0 text-base font-semibold text-gray-900">
469
+ {choice ? 'Explanation' : 'Suggested answer'}
470
+ </h3>
215
471
  <MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
216
472
  </section>
217
473
  )}
@@ -219,9 +475,18 @@ export function QuizPage() {
219
475
  )}
220
476
 
221
477
  <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>
478
+ <div className="flex gap-2">
479
+ <Button variant="outline" onClick={() => goToQuestion(Math.max(activeIndex - 1, 0))} disabled={activeIndex === 0}>
480
+ Previous
481
+ </Button>
482
+ <Button
483
+ variant="outline"
484
+ onClick={() => goToQuestion(Math.min(activeIndex + 1, quizQuestions.length - 1))}
485
+ disabled={activeIndex === quizQuestions.length - 1}
486
+ >
487
+ Next
488
+ </Button>
489
+ </div>
225
490
  <div className="ml-auto flex flex-wrap items-center justify-end gap-2">
226
491
  <Button variant="outline" onClick={() => updateQuestionStatus('unanswered')} disabled={session.completed}>
227
492
  Skip