question-bank-framework 0.2.0 → 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.
- 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 +246 -32
- 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,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
|
|
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
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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">
|
|
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
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
})
|