question-bank-framework 0.0.0-stage → 0.1.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.
Files changed (39) hide show
  1. package/README.md +105 -2
  2. package/bin/qb.js +119 -0
  3. package/bin/qb.ts +128 -0
  4. package/core/config.tsx +52 -0
  5. package/core/index.ts +35 -0
  6. package/core/types.ts +256 -0
  7. package/framework/components/app-shell.tsx +54 -0
  8. package/framework/components/filter-builder.tsx +471 -0
  9. package/framework/components/markdown-content.tsx +109 -0
  10. package/framework/components/question-bank.tsx +690 -0
  11. package/framework/components/quiz-list.tsx +118 -0
  12. package/framework/components/quiz-page.tsx +454 -0
  13. package/framework/components/ui/badge.tsx +47 -0
  14. package/framework/components/ui/button.tsx +63 -0
  15. package/framework/components/ui/checkbox.tsx +29 -0
  16. package/framework/components/ui/dropdown-menu.tsx +256 -0
  17. package/framework/components/ui/input.tsx +20 -0
  18. package/framework/components/ui/popover.tsx +88 -0
  19. package/framework/components/ui/select.tsx +187 -0
  20. package/framework/components/ui/sheet.tsx +140 -0
  21. package/framework/components/ui/table.tsx +121 -0
  22. package/framework/convex/questionStatuses.ts +30 -0
  23. package/framework/convex/quizzes.ts +50 -0
  24. package/framework/convex/schema.ts +18 -0
  25. package/framework/index.ts +26 -0
  26. package/framework/lib/convex-api.ts +36 -0
  27. package/framework/lib/question-filter-store.ts +116 -0
  28. package/framework/lib/question-filter.ts +376 -0
  29. package/framework/lib/question-status-storage.ts +117 -0
  30. package/framework/lib/quiz-storage.ts +118 -0
  31. package/framework/lib/utils.ts +6 -0
  32. package/package.json +66 -4
  33. package/template/bank.config.ts +72 -0
  34. package/template/content/answers.ts +8 -0
  35. package/template/content/questions.json +76 -0
  36. package/template/src/routes/__root.tsx +44 -0
  37. package/template/src/routes/index.tsx +4 -0
  38. package/template/src/routes/quiz.$quizId.tsx +4 -0
  39. package/template/src/routes/quizzes.tsx +4 -0
@@ -0,0 +1,118 @@
1
+ // Framework-owned: eject with `qb reveal framework/components/quiz-list.tsx`.
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { Link } from '@tanstack/react-router';
5
+ import { Trash2 } from 'lucide-react';
6
+ import { useBank } from '../../core/config.tsx';
7
+ import type { QuizSession } from '../../core/types.ts';
8
+ import { isAnswerProvided, scoreAnswers } from '../../core/types.ts';
9
+ import { Button } from './ui/button';
10
+ import { deleteQuiz, listQuizzes } from '../lib/quiz-storage.ts';
11
+
12
+ export function QuizList() {
13
+ const bank = useBank();
14
+ const [quizzes, setQuizzes] = useState<QuizSession[]>([]);
15
+ const [loading, setLoading] = useState(true);
16
+ const [deletingId, setDeletingId] = useState<string | null>(null);
17
+ const [deleteError, setDeleteError] = useState(false);
18
+
19
+ useEffect(() => {
20
+ let active = true;
21
+ listQuizzes(bank.namespace).then((items) => {
22
+ if (active) setQuizzes(items);
23
+ }).finally(() => {
24
+ if (active) setLoading(false);
25
+ });
26
+ return () => {
27
+ active = false;
28
+ };
29
+ }, [bank.namespace]);
30
+
31
+ const removeQuiz = async (quiz: QuizSession) => {
32
+ setDeletingId(quiz.id);
33
+ setDeleteError(false);
34
+ try {
35
+ await deleteQuiz(bank.namespace, quiz.id);
36
+ setQuizzes((current) => current.filter((item) => item.id !== quiz.id));
37
+ } catch {
38
+ setDeleteError(true);
39
+ } finally {
40
+ setDeletingId(null);
41
+ }
42
+ };
43
+
44
+ return (
45
+ <div className="grid gap-8">
46
+ <div className="flex flex-wrap items-end justify-between gap-4">
47
+ <div>
48
+ <h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">My quizzes</h1>
49
+ <p className="mt-2 text-base text-gray-600">Pick up where you left off. Your answers are saved as you go.</p>
50
+ </div>
51
+ <Button asChild>
52
+ <Link to="/">Build a quiz</Link>
53
+ </Button>
54
+ </div>
55
+
56
+ {loading ? (
57
+ <div className="py-16 text-center text-sm text-gray-600">Loading your quizzes...</div>
58
+ ) : quizzes.length > 0 ? (
59
+ <div>
60
+ {deleteError && (
61
+ <p role="alert" className="mb-3 text-sm text-red-700">
62
+ Could not delete that quiz. Please try again.
63
+ </p>
64
+ )}
65
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
66
+ {quizzes.map((quiz) => {
67
+ const questions = quiz.questionIds
68
+ .map((id) => bank.questionById.get(id))
69
+ .filter((question) => question !== undefined);
70
+ const answeredCount = questions.filter((question) =>
71
+ isAnswerProvided(question, quiz.answers[question.id]),
72
+ ).length;
73
+ const score = scoreAnswers(questions, quiz.answers);
74
+ return (
75
+ <div key={quiz.id} className="group relative">
76
+ <Link
77
+ to="/quiz/$quizId"
78
+ params={{ quizId: quiz.id }}
79
+ 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"
80
+ >
81
+ <h2 className="m-0 pr-10 text-lg font-semibold text-gray-900">{quiz.title}</h2>
82
+ <p className="mt-2 text-sm text-gray-700">
83
+ {quiz.questionIds.length} {quiz.questionIds.length === 1 ? 'question' : 'questions'}
84
+ </p>
85
+ <p className="mt-1 text-sm text-gray-600">
86
+ {answeredCount} of {quiz.questionIds.length} answered
87
+ {score.gradeable > 0 && ` • ${score.correct}/${score.gradeable} correct`}
88
+ </p>
89
+ </Link>
90
+ <Button
91
+ type="button"
92
+ variant="outline"
93
+ size="icon-xs"
94
+ aria-label={`Delete ${quiz.title}`}
95
+ title="Delete quiz"
96
+ disabled={deletingId === quiz.id}
97
+ onClick={() => removeQuiz(quiz)}
98
+ 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"
99
+ >
100
+ <Trash2 aria-hidden="true" />
101
+ </Button>
102
+ </div>
103
+ );
104
+ })}
105
+ </div>
106
+ </div>
107
+ ) : (
108
+ <div className="mx-auto max-w-xl py-16 text-center">
109
+ <h2 className="m-0 text-xl font-semibold text-gray-900">No quizzes yet</h2>
110
+ <p className="mt-2 text-base text-gray-600">Choose questions from the bank and turn them into a quiz that remembers your progress.</p>
111
+ <Button asChild className="mt-6">
112
+ <Link to="/">Browse questions</Link>
113
+ </Button>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,454 @@
1
+ // Framework-owned: eject with `qb reveal framework/components/quiz-page.tsx`.
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Link, useParams } from '@tanstack/react-router';
5
+ import { Check, Pencil, X } from 'lucide-react';
6
+ import { useBank } from '../../core/config.tsx';
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';
18
+ import { Badge } from './ui/badge';
19
+ import { Button } from './ui/button';
20
+ import { Input } from './ui/input';
21
+ import { MarkdownContent } from './markdown-content';
22
+ import { setQuestionStatus } from '../lib/question-status-storage.ts';
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);
27
+
28
+ export function QuizPage() {
29
+ const bank = useBank();
30
+ const { quizId } = useParams({ from: '/quiz/$quizId' });
31
+ const [session, setSession] = useState<QuizSession | null>(null);
32
+ const [loading, setLoading] = useState(true);
33
+ const [saving, setSaving] = useState(false);
34
+ const [shownAnswerId, setShownAnswerId] = useState<string | null>(null);
35
+ const [checked, setChecked] = useState<Record<string, boolean>>({});
36
+ const [editingTitle, setEditingTitle] = useState(false);
37
+ const [titleDraft, setTitleDraft] = useState('');
38
+ const titleInputRef = useRef<HTMLInputElement>(null);
39
+ const quizQuestions = useMemo(
40
+ () =>
41
+ session?.questionIds.map((id) => bank.questionById.get(id)).filter((question) => question !== undefined) ?? [],
42
+ [session, bank],
43
+ );
44
+ const activeIndex = Math.min(session?.currentQuestion ?? 0, Math.max(quizQuestions.length - 1, 0));
45
+ const activeQuestion = quizQuestions[activeIndex];
46
+
47
+ useEffect(() => {
48
+ let active = true;
49
+ setShownAnswerId(null);
50
+ setChecked({});
51
+ getQuiz(bank.namespace, quizId).then((quiz) => {
52
+ if (active) setSession(quiz);
53
+ }).finally(() => {
54
+ if (active) setLoading(false);
55
+ });
56
+ return () => {
57
+ active = false;
58
+ };
59
+ }, [quizId, bank.namespace]);
60
+
61
+ useEffect(() => {
62
+ if (!session) return;
63
+ saveQuizLocally(bank.namespace, session);
64
+ if (session.id.startsWith('local_')) return;
65
+ setSaving(true);
66
+ const timeout = window.setTimeout(() => {
67
+ saveQuizProgress(
68
+ bank.namespace,
69
+ session,
70
+ { title: session.title, answers: session.answers, currentQuestion: session.currentQuestion, completed: session.completed },
71
+ )
72
+ .then(() => setSaving(false))
73
+ .catch(() => setSaving(false));
74
+ }, 500);
75
+ return () => window.clearTimeout(timeout);
76
+ }, [session, bank.namespace]);
77
+
78
+ useEffect(() => {
79
+ if (editingTitle) {
80
+ titleInputRef.current?.focus();
81
+ titleInputRef.current?.select();
82
+ }
83
+ }, [editingTitle]);
84
+
85
+ const goToQuestion = (index: number) => {
86
+ setShownAnswerId(null);
87
+ setSession((current) => (current ? { ...current, currentQuestion: index, completed: false } : current));
88
+ };
89
+
90
+ const updateWrittenAnswer = (value: string) => {
91
+ if (!activeQuestion) return;
92
+ setSession((current) =>
93
+ current ? { ...current, completed: false, answers: { ...current.answers, [activeQuestion.id]: value } } : current,
94
+ );
95
+ };
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
+
123
+ const updateQuestionStatus = (status: QuestionStatus) => {
124
+ if (!activeQuestion) return;
125
+ void setQuestionStatus(bank.namespace, activeQuestion.id, status);
126
+ setShownAnswerId(null);
127
+ setSession((current) =>
128
+ current
129
+ ? {
130
+ ...current,
131
+ currentQuestion: Math.min(activeIndex + 1, quizQuestions.length - 1),
132
+ completed: activeIndex === quizQuestions.length - 1,
133
+ }
134
+ : current,
135
+ );
136
+ };
137
+
138
+ const startEditingTitle = () => {
139
+ if (!session) return;
140
+ setTitleDraft(session.title);
141
+ setEditingTitle(true);
142
+ };
143
+
144
+ const saveTitle = () => {
145
+ const title = titleDraft.trim();
146
+ if (!title) return;
147
+ setSession((current) => (current ? { ...current, title } : current));
148
+ setEditingTitle(false);
149
+ };
150
+
151
+ if (loading) return <div className="py-16 text-center text-sm text-gray-600">Opening your quiz...</div>;
152
+
153
+ if (!session || !activeQuestion || quizQuestions.length === 0) {
154
+ return (
155
+ <div className="mx-auto max-w-xl py-16 text-center">
156
+ <h1 className="text-2xl font-bold text-gray-900">Quiz not found</h1>
157
+ <p className="mt-2 text-base text-gray-600">This practice set may have been removed from this browser.</p>
158
+ <Button asChild className="mt-6">
159
+ <Link to="/quizzes">Back to My quizzes</Link>
160
+ </Button>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ const answer: QuizAnswer | undefined = session.answers[activeQuestion.id];
166
+ const suggestedAnswer = bank.answers[activeQuestion.id];
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);
176
+
177
+ return (
178
+ <div className="mx-auto grid max-w-3xl gap-6">
179
+ <Link to="/quizzes" className="text-sm font-semibold text-gray-600 hover:text-gray-900">
180
+ Back to My quizzes
181
+ </Link>
182
+
183
+ <div className="flex flex-wrap items-center justify-between gap-2">
184
+ {editingTitle ? (
185
+ <form
186
+ className="flex min-w-0 flex-1 items-center gap-2"
187
+ onSubmit={(event) => {
188
+ event.preventDefault();
189
+ saveTitle();
190
+ }}
191
+ >
192
+ <Input
193
+ ref={titleInputRef}
194
+ aria-label="Quiz title"
195
+ value={titleDraft}
196
+ onChange={(event) => setTitleDraft(event.target.value)}
197
+ onKeyDown={(event) => {
198
+ if (event.key === 'Escape') setEditingTitle(false);
199
+ }}
200
+ className="h-auto min-w-0 border-gray-300 px-2 py-1 text-2xl font-bold tracking-tight md:text-3xl"
201
+ />
202
+ <Button type="submit" size="icon-sm" aria-label="Save title" title="Save title" disabled={!titleDraft.trim()}>
203
+ <Check aria-hidden="true" />
204
+ </Button>
205
+ <Button type="button" variant="ghost" size="icon-sm" aria-label="Cancel title edit" title="Cancel" onClick={() => setEditingTitle(false)}>
206
+ <X aria-hidden="true" />
207
+ </Button>
208
+ </form>
209
+ ) : (
210
+ <div className="flex min-w-0 items-center gap-2">
211
+ <h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">{session.title}</h1>
212
+ <Button type="button" variant="ghost" size="icon-xs" aria-label="Edit quiz title" title="Edit quiz title" onClick={startEditingTitle}>
213
+ <Pencil aria-hidden="true" />
214
+ </Button>
215
+ </div>
216
+ )}
217
+ <span className="text-sm text-gray-600">{saving ? 'Saving...' : 'Saved'}</span>
218
+ </div>
219
+
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
+ )}
274
+
275
+ <section className="rounded-xl border border-gray-200 bg-white p-5">
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>
280
+ <p className="mt-1 font-mono text-xs text-gray-500">{activeQuestion.id}</p>
281
+ {activeQuestion.tags.length > 0 && (
282
+ <div className="mt-2 flex flex-wrap gap-1">
283
+ {activeQuestion.tags.map((tag) => (
284
+ <Badge key={tag} variant="secondary">
285
+ {tag}
286
+ </Badge>
287
+ ))}
288
+ </div>
289
+ )}
290
+ <MarkdownContent content={activeQuestion.prompt} className="mt-4 text-base leading-relaxed text-gray-800" />
291
+
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>
352
+
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) && (
404
+ <div className="mt-5">
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
+ )}
415
+ {isSuggestedAnswerShown && (
416
+ <section id="suggested-answer" className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
417
+ <h3 className="m-0 text-base font-semibold text-gray-900">
418
+ {choice ? 'Explanation' : 'Suggested answer'}
419
+ </h3>
420
+ <MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
421
+ </section>
422
+ )}
423
+ </div>
424
+ )}
425
+
426
+ <div className="mt-6 flex flex-wrap items-center justify-between gap-3">
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>
439
+ <div className="ml-auto flex flex-wrap items-center justify-end gap-2">
440
+ <Button variant="outline" onClick={() => updateQuestionStatus('unanswered')} disabled={session.completed}>
441
+ Skip
442
+ </Button>
443
+ <Button onClick={() => updateQuestionStatus('revisit')} disabled={session.completed} className="bg-amber-400 text-gray-950 hover:bg-amber-500">
444
+ Revisit
445
+ </Button>
446
+ <Button onClick={() => updateQuestionStatus('complete')} disabled={session.completed}>
447
+ {session.completed ? 'Quiz complete' : 'Mark as complete'}
448
+ </Button>
449
+ </div>
450
+ </div>
451
+ </section>
452
+ </div>
453
+ );
454
+ }
@@ -0,0 +1,47 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { Slot } from "radix-ui"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
12
+ secondary:
13
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
14
+ destructive:
15
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
16
+ outline:
17
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
18
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
19
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: "default",
24
+ },
25
+ }
26
+ )
27
+
28
+ function Badge({
29
+ className,
30
+ variant = "default",
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<"span"> &
34
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot.Root : "span"
36
+
37
+ return (
38
+ <Comp
39
+ data-slot="badge"
40
+ data-variant={variant}
41
+ className={cn(badgeVariants({ variant }), className)}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ export { Badge, badgeVariants }
@@ -0,0 +1,63 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { Slot } from "radix-ui"
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
12
+ destructive:
13
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
14
+ outline:
15
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
16
+ secondary:
17
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
18
+ ghost:
19
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
24
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
25
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
26
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27
+ icon: "size-9",
28
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
29
+ "icon-sm": "size-8",
30
+ "icon-lg": "size-10",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ variant: "default",
35
+ size: "default",
36
+ },
37
+ }
38
+ )
39
+
40
+ function Button({
41
+ className,
42
+ variant = "default",
43
+ size = "default",
44
+ asChild = false,
45
+ ...props
46
+ }: React.ComponentProps<"button"> &
47
+ VariantProps<typeof buttonVariants> & {
48
+ asChild?: boolean
49
+ }) {
50
+ const Comp = asChild ? Slot.Root : "button"
51
+
52
+ return (
53
+ <Comp
54
+ data-slot="button"
55
+ data-variant={variant}
56
+ data-size={size}
57
+ className={cn(buttonVariants({ variant, size, className }))}
58
+ {...props}
59
+ />
60
+ )
61
+ }
62
+
63
+ export { Button, buttonVariants }