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.
- package/README.md +105 -2
- package/bin/qb.js +119 -0
- package/bin/qb.ts +128 -0
- package/core/config.tsx +52 -0
- package/core/index.ts +35 -0
- package/core/types.ts +256 -0
- package/framework/components/app-shell.tsx +54 -0
- package/framework/components/filter-builder.tsx +471 -0
- package/framework/components/markdown-content.tsx +109 -0
- package/framework/components/question-bank.tsx +690 -0
- package/framework/components/quiz-list.tsx +118 -0
- package/framework/components/quiz-page.tsx +454 -0
- package/framework/components/ui/badge.tsx +47 -0
- package/framework/components/ui/button.tsx +63 -0
- package/framework/components/ui/checkbox.tsx +29 -0
- package/framework/components/ui/dropdown-menu.tsx +256 -0
- package/framework/components/ui/input.tsx +20 -0
- package/framework/components/ui/popover.tsx +88 -0
- package/framework/components/ui/select.tsx +187 -0
- package/framework/components/ui/sheet.tsx +140 -0
- package/framework/components/ui/table.tsx +121 -0
- package/framework/convex/questionStatuses.ts +30 -0
- package/framework/convex/quizzes.ts +50 -0
- package/framework/convex/schema.ts +18 -0
- package/framework/index.ts +26 -0
- package/framework/lib/convex-api.ts +36 -0
- package/framework/lib/question-filter-store.ts +116 -0
- package/framework/lib/question-filter.ts +376 -0
- package/framework/lib/question-status-storage.ts +117 -0
- package/framework/lib/quiz-storage.ts +118 -0
- package/framework/lib/utils.ts +6 -0
- package/package.json +66 -4
- package/template/bank.config.ts +72 -0
- package/template/content/answers.ts +8 -0
- package/template/content/questions.json +76 -0
- package/template/src/routes/__root.tsx +44 -0
- package/template/src/routes/index.tsx +4 -0
- package/template/src/routes/quiz.$quizId.tsx +4 -0
- 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 }
|