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.
- package/README.md +14 -0
- package/core/config.tsx +14 -0
- package/core/index.ts +19 -1
- package/core/types.ts +153 -1
- package/framework/components/question-bank.tsx +181 -25
- package/framework/components/quiz-list.tsx +52 -16
- package/framework/components/quiz-page.tsx +298 -33
- package/framework/components/quiz-question-editor.tsx +171 -0
- package/framework/convex/quizzes.ts +13 -1
- package/framework/convex/schema.ts +1 -1
- package/framework/lib/convex-api.ts +9 -2
- package/framework/lib/question-filter.ts +25 -7
- package/framework/lib/quiz-storage.ts +22 -0
- package/framework/styles/theme.css +58 -0
- package/package.json +1 -2
- package/template/content/answers.ts +2 -0
- package/template/content/questions.json +41 -0
- package/template/src/routes/styles.css +7 -0
|
@@ -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 {
|
|
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
|
|
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-
|
|
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
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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">
|
|
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
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
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
|