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
|
@@ -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
|
+
})
|
|
@@ -5,7 +5,7 @@ export default defineSchema({
|
|
|
5
5
|
quizzes: defineTable({
|
|
6
6
|
title: v.string(),
|
|
7
7
|
questionIds: v.array(v.string()),
|
|
8
|
-
answers: v.record(v.string(), v.string()),
|
|
8
|
+
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
|
9
9
|
currentQuestion: v.number(),
|
|
10
10
|
completed: v.boolean(),
|
|
11
11
|
createdAt: v.number(),
|
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
// Framework-owned: eject with `qb reveal framework/lib/convex-api.ts`.
|
|
3
3
|
|
|
4
4
|
import { makeFunctionReference } from 'convex/server';
|
|
5
|
-
import type { QuestionStatus, QuizSession } from '../../core/types.ts';
|
|
5
|
+
import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
|
|
6
6
|
|
|
7
7
|
type CreateQuizArgs = { title: string; questionIds: string[] };
|
|
8
8
|
type ProgressArgs = {
|
|
9
9
|
quizId: string;
|
|
10
10
|
title: string;
|
|
11
|
-
answers: Record<string,
|
|
11
|
+
answers: Record<string, QuizAnswer>;
|
|
12
12
|
currentQuestion: number;
|
|
13
13
|
completed: boolean;
|
|
14
14
|
};
|
|
15
|
+
type SetQuestionsArgs = {
|
|
16
|
+
quizId: string;
|
|
17
|
+
questionIds: string[];
|
|
18
|
+
answers: Record<string, QuizAnswer>;
|
|
19
|
+
currentQuestion: number;
|
|
20
|
+
};
|
|
15
21
|
type QuizDocument = QuizSession & { _id: string; _creationTime: number };
|
|
16
22
|
type QuestionStatusDocument = {
|
|
17
23
|
_id: string;
|
|
@@ -28,6 +34,7 @@ export const convexApi = {
|
|
|
28
34
|
list: makeFunctionReference<'query', Record<string, never>, QuizDocument[]>('quizzes:list'),
|
|
29
35
|
remove: makeFunctionReference<'mutation', { quizId: string }, null>('quizzes:remove'),
|
|
30
36
|
saveProgress: makeFunctionReference<'mutation', ProgressArgs, null>('quizzes:saveProgress'),
|
|
37
|
+
setQuestions: makeFunctionReference<'mutation', SetQuestionsArgs, null>('quizzes:setQuestions'),
|
|
31
38
|
},
|
|
32
39
|
questionStatuses: {
|
|
33
40
|
list: makeFunctionReference<'query', Record<string, never>, QuestionStatusDocument[]>('questionStatuses:list'),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// by config-defined `BankFieldDef[]` plus the three built-ins.
|
|
4
4
|
// Framework-owned: eject with `qb reveal framework/lib/question-filter.ts`.
|
|
5
5
|
|
|
6
|
-
import { questionStatuses, type BankFieldDef, type BankFilterOperatorId, type BaseQuestion, type QuestionStatus } from '../../core/types.ts';
|
|
6
|
+
import { questionKind, questionKindLabels, questionKinds, questionStatuses, type BankFieldDef, type BankFilterOperatorId, type BaseQuestion, type QuestionStatus } from '../../core/types.ts';
|
|
7
7
|
|
|
8
8
|
export type FilterMatch = 'and' | 'or';
|
|
9
9
|
|
|
@@ -101,6 +101,13 @@ function builtinFields(): FilterField[] {
|
|
|
101
101
|
{ id: 'title', label: 'Question', type: 'text', operators: textOperators, placeholder: 'Type a value…' },
|
|
102
102
|
{ id: 'id', label: 'ID', type: 'text', operators: textOperators, placeholder: 'Type a value…' },
|
|
103
103
|
{ id: 'tags', label: 'Tags', type: 'multiselect', operators: multiSelectOperators },
|
|
104
|
+
{
|
|
105
|
+
id: 'kind',
|
|
106
|
+
label: 'Type',
|
|
107
|
+
type: 'select',
|
|
108
|
+
operators: selectOperators,
|
|
109
|
+
options: questionKinds.map((kind) => ({ value: kind, label: questionKindLabels[kind] })),
|
|
110
|
+
},
|
|
104
111
|
{
|
|
105
112
|
id: 'status',
|
|
106
113
|
label: 'Status',
|
|
@@ -128,7 +135,11 @@ function distinctStrings(values: Array<string | number | null | readonly string[
|
|
|
128
135
|
* Build filter fields from bank config. Select/multiselect options default to
|
|
129
136
|
* the distinct values across questions unless the field declares `options`.
|
|
130
137
|
*/
|
|
131
|
-
export function createFilterFields(
|
|
138
|
+
export function createFilterFields(
|
|
139
|
+
questions: readonly BaseQuestion[],
|
|
140
|
+
fieldDefs: readonly BankFieldDef[],
|
|
141
|
+
hideTagsColumn = false,
|
|
142
|
+
): FilterField[] {
|
|
132
143
|
const customs: FilterField[] = fieldDefs.map((def) => {
|
|
133
144
|
let options: readonly FilterOption[] | undefined;
|
|
134
145
|
if (def.options) {
|
|
@@ -156,7 +167,7 @@ export function createFilterFields(questions: readonly BaseQuestion[], fieldDefs
|
|
|
156
167
|
};
|
|
157
168
|
});
|
|
158
169
|
|
|
159
|
-
const builtins = builtinFields();
|
|
170
|
+
const builtins = builtinFields().filter((field) => !(hideTagsColumn && field.id === 'tags'));
|
|
160
171
|
// Attach tag options derived from the bank (like the original).
|
|
161
172
|
const tags = [...new Set(questions.flatMap((q) => q.tags))].sort((a, b) => a.localeCompare(b));
|
|
162
173
|
const withTags = builtins.map((field) =>
|
|
@@ -220,8 +231,8 @@ function resolveFieldValue(
|
|
|
220
231
|
if (ruleField === 'title') return question.title;
|
|
221
232
|
if (ruleField === 'id') return question.id;
|
|
222
233
|
if (ruleField === 'tags') return question.tags;
|
|
223
|
-
// status
|
|
224
|
-
// is stale (no def, not a built-in) — match nothing.
|
|
234
|
+
// status/kind are handled by matchesRule before reaching here. Any other
|
|
235
|
+
// id is stale (no def, not a built-in) — match nothing.
|
|
225
236
|
return null;
|
|
226
237
|
}
|
|
227
238
|
return def.getValue(question);
|
|
@@ -323,6 +334,7 @@ export function matchesRule(
|
|
|
323
334
|
): boolean {
|
|
324
335
|
const type = filterFieldType(fields, rule.field);
|
|
325
336
|
if (rule.field === 'status') return matchSelect(status, rule);
|
|
337
|
+
if (rule.field === 'kind') return matchSelect(questionKind(question), rule);
|
|
326
338
|
const def = fieldDefs.find((d) => d.id === rule.field);
|
|
327
339
|
const raw = resolveFieldValue(def, rule.field, question);
|
|
328
340
|
if (type === 'text') return matchText(toText(raw), rule);
|
|
@@ -346,7 +358,7 @@ export function matchesFilters(
|
|
|
346
358
|
: active.some((rule) => matchesRule(rule, fields, fieldDefs, question, status));
|
|
347
359
|
}
|
|
348
360
|
|
|
349
|
-
/** Free-text search across id/title/prompt/tags plus `searchable` custom fields. */
|
|
361
|
+
/** Free-text search across id/title/prompt/tags/options plus `searchable` custom fields. */
|
|
350
362
|
export function matchesSearch(
|
|
351
363
|
question: BaseQuestion,
|
|
352
364
|
query: string,
|
|
@@ -354,7 +366,13 @@ export function matchesSearch(
|
|
|
354
366
|
): boolean {
|
|
355
367
|
const q = query.trim().toLowerCase();
|
|
356
368
|
if (!q) return true;
|
|
357
|
-
const parts = [
|
|
369
|
+
const parts = [
|
|
370
|
+
question.id,
|
|
371
|
+
question.title,
|
|
372
|
+
question.prompt,
|
|
373
|
+
question.tags.join(' '),
|
|
374
|
+
(question.options ?? []).map((o) => o.text).join(' '),
|
|
375
|
+
];
|
|
358
376
|
for (const def of fieldDefs) {
|
|
359
377
|
if (!def.searchable) continue;
|
|
360
378
|
const raw = def.getValue(question);
|
|
@@ -104,6 +104,28 @@ export async function saveQuizProgress(
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// Replaces a quiz's question list (reorder / remove / append). Answers for
|
|
108
|
+
// removed questions are dropped and the current position is kept on the same
|
|
109
|
+
// question where possible.
|
|
110
|
+
export async function setQuizQuestions(namespace: string, session: QuizSession, questionIds: string[]): Promise<QuizSession> {
|
|
111
|
+
const kept = new Set(questionIds);
|
|
112
|
+
const answers = Object.fromEntries(Object.entries(session.answers).filter(([id]) => kept.has(id)));
|
|
113
|
+
const currentId = session.questionIds[session.currentQuestion];
|
|
114
|
+
const currentIndex = currentId === undefined ? -1 : questionIds.indexOf(currentId);
|
|
115
|
+
const currentQuestion = currentIndex >= 0 ? currentIndex : Math.min(session.currentQuestion, Math.max(questionIds.length - 1, 0));
|
|
116
|
+
const updated: QuizSession = { ...session, questionIds, answers, currentQuestion };
|
|
117
|
+
if (convexClient && !updated.id.startsWith('local_')) {
|
|
118
|
+
await convexClient.mutation(convexApi.quizzes.setQuestions, {
|
|
119
|
+
quizId: updated.id,
|
|
120
|
+
questionIds,
|
|
121
|
+
answers,
|
|
122
|
+
currentQuestion,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
saveQuizLocally(namespace, updated);
|
|
126
|
+
return updated;
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
export async function listQuizzes(namespace: string): Promise<QuizSession[]> {
|
|
108
130
|
const localQuizzes = readLocalQuizzes(namespace);
|
|
109
131
|
if (!convexClient) return localQuizzes;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* shadcn-style theme tokens consumed by question-bank-framework UI components.
|
|
2
|
+
* Import this from your app stylesheet (after tailwindcss) instead of
|
|
3
|
+
* copy-pasting the tokens, so every bank inherits upstream token changes:
|
|
4
|
+
*
|
|
5
|
+
* @import 'tailwindcss';
|
|
6
|
+
* @source '../node_modules/question-bank-framework';
|
|
7
|
+
* @import 'question-bank-framework/framework/styles/theme.css';
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
@theme inline {
|
|
11
|
+
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
|
|
12
|
+
--font-display: 'Fraunces', Georgia, serif;
|
|
13
|
+
--font-mono: 'DM Mono', ui-monospace, monospace;
|
|
14
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
15
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
16
|
+
--radius-lg: var(--radius);
|
|
17
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
18
|
+
--color-background: var(--background);
|
|
19
|
+
--color-foreground: var(--foreground);
|
|
20
|
+
--color-card: var(--card);
|
|
21
|
+
--color-card-foreground: var(--card-foreground);
|
|
22
|
+
--color-popover: var(--popover);
|
|
23
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
24
|
+
--color-primary: var(--primary);
|
|
25
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
26
|
+
--color-secondary: var(--secondary);
|
|
27
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
28
|
+
--color-muted: var(--muted);
|
|
29
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
30
|
+
--color-accent: var(--accent);
|
|
31
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
32
|
+
--color-destructive: var(--destructive);
|
|
33
|
+
--color-border: var(--border);
|
|
34
|
+
--color-input: var(--input);
|
|
35
|
+
--color-ring: var(--ring);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
:root {
|
|
39
|
+
--radius: 0.625rem;
|
|
40
|
+
--background: #ffffff;
|
|
41
|
+
--foreground: #111827;
|
|
42
|
+
--card: #ffffff;
|
|
43
|
+
--card-foreground: #111827;
|
|
44
|
+
--popover: #ffffff;
|
|
45
|
+
--popover-foreground: #111827;
|
|
46
|
+
--primary: #25564c;
|
|
47
|
+
--primary-foreground: #ffffff;
|
|
48
|
+
--secondary: #f1f5f2;
|
|
49
|
+
--secondary-foreground: #263a2f;
|
|
50
|
+
--muted: #f3f4f6;
|
|
51
|
+
--muted-foreground: #6b7280;
|
|
52
|
+
--accent: #eef3ef;
|
|
53
|
+
--accent-foreground: #263a2f;
|
|
54
|
+
--destructive: #b91c1c;
|
|
55
|
+
--border: #d1d5db;
|
|
56
|
+
--input: #d1d5db;
|
|
57
|
+
--ring: #78a589;
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "question-bank-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,7 +62,6 @@
|
|
|
62
62
|
"version-packages": "changeset version",
|
|
63
63
|
"release": "pnpm build:bin && changeset publish",
|
|
64
64
|
"reveal": "node ./bin/qb.js reveal",
|
|
65
|
-
"template:check": "node ./bin/qb.js check-template",
|
|
66
65
|
"build:bin": "tsc bin/qb.ts --ignoreConfig --outDir bin --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --types node && chmod +x bin/qb.js"
|
|
67
66
|
}
|
|
68
67
|
}
|
|
@@ -3,4 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
export const answers: Record<string, string> = {
|
|
5
5
|
'2019-s1-q01': String.raw`**(a)** Not well-formed: several sibling top-level elements, so no single root.`,
|
|
6
|
+
'2019-s1-mc01': String.raw`Only **B** has a single root element with matching open and close tags. A never closes, C closes in the wrong order, and D is not an element at all.`,
|
|
7
|
+
'2019-s1-mc02': String.raw`Remove or correct physically impossible values (**A**), always investigate before discarding (**C**), and document any imputation (**D**). Deleting everything above the mean (**B**) discards roughly half of legitimate data.`,
|
|
6
8
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"id": "2019-s1-q01",
|
|
4
|
+
"kind": "written",
|
|
4
5
|
"year": 2019,
|
|
5
6
|
"semester": "S1",
|
|
6
7
|
"number": 1,
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
},
|
|
13
14
|
{
|
|
14
15
|
"id": "2019-s1-q02",
|
|
16
|
+
"kind": "written",
|
|
15
17
|
"year": 2019,
|
|
16
18
|
"semester": "S1",
|
|
17
19
|
"number": 2,
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
},
|
|
24
26
|
{
|
|
25
27
|
"id": "ioa-001",
|
|
28
|
+
"kind": "written",
|
|
26
29
|
"year": 2026,
|
|
27
30
|
"semester": "S2",
|
|
28
31
|
"number": 1,
|
|
@@ -31,5 +34,43 @@
|
|
|
31
34
|
"prompt": "What is your research question, and what result would count as evidence?",
|
|
32
35
|
"source": "assignment-ioa",
|
|
33
36
|
"tags": ["Assignment questions", "Justification"]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "2019-s1-mc01",
|
|
40
|
+
"kind": "single",
|
|
41
|
+
"year": 2019,
|
|
42
|
+
"semester": "S1",
|
|
43
|
+
"number": 3,
|
|
44
|
+
"marks": 1,
|
|
45
|
+
"title": "Well-formed XML",
|
|
46
|
+
"prompt": "Which of the following XML documents is well-formed?",
|
|
47
|
+
"options": [
|
|
48
|
+
{ "id": "a", "text": "`<subject>COMP20008<subject>`" },
|
|
49
|
+
{ "id": "b", "text": "`<subject>COMP20008</subject>`" },
|
|
50
|
+
{ "id": "c", "text": "`<subject><topic>COMP20008</subject></topic>`" },
|
|
51
|
+
{ "id": "d", "text": "`COMP20008`" }
|
|
52
|
+
],
|
|
53
|
+
"correctIds": ["b"],
|
|
54
|
+
"source": "2019-s1.md",
|
|
55
|
+
"tags": ["Data fundamentals", "Data, formats & databases"]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "2019-s1-mc02",
|
|
59
|
+
"kind": "multiple",
|
|
60
|
+
"year": 2019,
|
|
61
|
+
"semester": "S1",
|
|
62
|
+
"number": 4,
|
|
63
|
+
"marks": 2,
|
|
64
|
+
"title": "Outlier handling",
|
|
65
|
+
"prompt": "A temperature sensor reports occasional extreme spikes. Which of the following are reasonable outlier-handling steps? **Select all that apply.**",
|
|
66
|
+
"options": [
|
|
67
|
+
{ "id": "a", "text": "Remove spikes that are physically impossible for the sensor" },
|
|
68
|
+
{ "id": "b", "text": "Delete every reading above the mean" },
|
|
69
|
+
{ "id": "c", "text": "Flag spikes and investigate whether they coincide with known events" },
|
|
70
|
+
{ "id": "d", "text": "Replace spikes with interpolated values and document the change" }
|
|
71
|
+
],
|
|
72
|
+
"correctIds": ["a", "c", "d"],
|
|
73
|
+
"source": "2019-s1.md",
|
|
74
|
+
"tags": ["Data preparation", "Data quality & preparation"]
|
|
34
75
|
}
|
|
35
76
|
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap');
|
|
2
|
+
@import 'katex/dist/katex.min.css';
|
|
3
|
+
@import 'tailwindcss';
|
|
4
|
+
@import 'tw-animate-css';
|
|
5
|
+
@import 'question-bank-framework/framework/styles/theme.css';
|
|
6
|
+
|
|
7
|
+
@source '../../node_modules/question-bank-framework';
|