question-bank-framework 0.1.1 → 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 CHANGED
@@ -24,6 +24,20 @@ Your app owns:
24
24
  - `content/questions.json`, `content/answers.ts`, `content/figures/`
25
25
  - Thin TanStack Start routes in `src/routes/` (see `template/src/routes/`)
26
26
  wrapping `<BankProvider bank={bank}>` + `<AppShell brand>` + framework pages.
27
+ - `src/routes/styles.css` (scaffolded by `qb init`, imported as `?url` from
28
+ `__root.tsx`). It must list the framework as a Tailwind source — v4 ignores
29
+ `node_modules` by default, so without this none of the grid/badge/filter
30
+ utilities are generated and the app renders unstyled:
31
+
32
+ ```css
33
+ @import 'tailwindcss';
34
+ @import 'question-bank-framework/framework/styles/theme.css';
35
+ @source '../node_modules/question-bank-framework'; /* relative to this file */
36
+ ```
37
+
38
+ Pass `hideTagsColumn: true` to `defineBank` when tags merely duplicate
39
+ dedicated fields (every tag equals a topic/type value already in its own
40
+ column). Tags still feed free-text search.
27
41
 
28
42
  ## Reveal (eject) files
29
43
 
package/core/config.tsx CHANGED
@@ -31,8 +31,22 @@ export type {
31
31
  BankSiteConfig,
32
32
  BaseQuestion,
33
33
  DefineBankInput,
34
+ QuestionKind,
35
+ QuestionOption,
34
36
  QuestionStatus,
35
37
  QuestionStatusMap,
38
+ QuizAnswer,
36
39
  QuizSession,
37
40
  } from './types.ts';
41
+ export {
42
+ getCorrectIds,
43
+ isAnswerCorrect,
44
+ isAnswerProvided,
45
+ isChoiceQuestion,
46
+ questionKind,
47
+ questionKindLabels,
48
+ questionKinds,
49
+ scoreAnswers,
50
+ toSelectedIds,
51
+ } from './types.ts';
38
52
  export { questionStatuses } from './types.ts';
package/core/index.ts CHANGED
@@ -1,7 +1,22 @@
1
1
  // Re-export the stable core: types + BankContext/useBank/defineBank.
2
2
  // Framework code (ejectable) lives under framework/.
3
3
 
4
- export { BankContext, BankProvider, useBank, defineBank, questionStatuses } from './config.tsx';
4
+ export {
5
+ BankContext,
6
+ BankProvider,
7
+ useBank,
8
+ defineBank,
9
+ getCorrectIds,
10
+ isAnswerCorrect,
11
+ isAnswerProvided,
12
+ isChoiceQuestion,
13
+ questionKind,
14
+ questionKindLabels,
15
+ questionKinds,
16
+ questionStatuses,
17
+ scoreAnswers,
18
+ toSelectedIds,
19
+ } from './config.tsx';
5
20
  export type {
6
21
  Bank,
7
22
  BankFieldDef,
@@ -11,7 +26,10 @@ export type {
11
26
  BankSiteConfig,
12
27
  BaseQuestion,
13
28
  DefineBankInput,
29
+ QuestionKind,
30
+ QuestionOption,
14
31
  QuestionStatus,
15
32
  QuestionStatusMap,
33
+ QuizAnswer,
16
34
  QuizSession,
17
35
  } from './config.tsx';
package/core/types.ts CHANGED
@@ -1,26 +1,56 @@
1
1
  // Core question + bank types. Framework-owned, never ejected.
2
- // A bank's question shape is config-defined: every question has the four
3
- // core fields, plus arbitrary subject-specific metadata (year, marks, …).
2
+ // Every question has the core fields below; subjects extend the interface
3
+ // with their own metadata (year, marks, …) and the bank stays generic over
4
+ // it — no index signatures, no `unknown`, no casts in subject code.
4
5
 
5
6
  export const questionStatuses = ['unanswered', 'revisit', 'complete'] as const;
6
7
 
7
8
  export type QuestionStatus = (typeof questionStatuses)[number];
8
9
  export type QuestionStatusMap = Record<string, QuestionStatus>;
9
10
 
11
+ // ---------------------------------------------------------------------------
12
+ // Question kinds: written (free response) vs choice (auto-gradeable).
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export type QuestionKind = 'written' | 'single' | 'multiple';
16
+
17
+ export const questionKinds: readonly QuestionKind[] = ['written', 'single', 'multiple'];
18
+
19
+ export const questionKindLabels: Record<QuestionKind, string> = {
20
+ written: 'Written',
21
+ single: 'Single choice',
22
+ multiple: 'Multiple choice',
23
+ };
24
+
25
+ export interface QuestionOption {
26
+ /** Stable within the question, e.g. "a", "b", "c". */
27
+ id: string;
28
+ /** Option body. Markdown supported. */
29
+ text: string;
30
+ }
31
+
32
+ /** Written answers are free text; choice answers are selected option ids. */
33
+ export type QuizAnswer = string | string[];
34
+
10
35
  export interface BaseQuestion {
11
36
  id: string;
12
37
  title: string;
13
38
  prompt: string;
14
39
  tags: string[];
15
- // Subject-specific fields (e.g. year, semester, marks, source).
16
- [key: string]: unknown;
40
+ /** Defaults to 'written' when omitted. Choice kinds require options + correctIds. */
41
+ kind?: QuestionKind;
42
+ /** Choice options. Required for single/multiple, forbidden for written. */
43
+ options?: QuestionOption[];
44
+ /** Correct option ids. Single choice requires exactly one. */
45
+ correctIds?: string[];
46
+ // Subjects extend this interface with their own fields; see template/bank.config.ts.
17
47
  }
18
48
 
19
49
  export interface QuizSession {
20
50
  id: string;
21
51
  title: string;
22
52
  questionIds: string[];
23
- answers: Record<string, string>;
53
+ answers: Record<string, QuizAnswer>;
24
54
  currentQuestion: number;
25
55
  completed: boolean;
26
56
  createdAt: number;
@@ -54,13 +84,19 @@ export interface BankFieldOption {
54
84
  label: string;
55
85
  }
56
86
 
57
- export interface BankFieldDef {
87
+ export interface BankFieldDef<Q extends BaseQuestion = BaseQuestion> {
58
88
  /** Stable field id. Reserved: id, title, tags, status. */
59
89
  id: string;
60
90
  label: string;
61
91
  type: BankFieldType;
62
- /** Extract the raw value from a question. */
63
- getValue: (question: BaseQuestion) => string | number | null | readonly string[];
92
+ /**
93
+ * Extract the raw value from a question. Q is inferred from the bank's
94
+ * questions. Method syntax (not a function property) keeps subject field
95
+ * defs assignable to the stored bank with zero casts — standard
96
+ * bivariant-method assignability, like Map/Set methods. Sound here
97
+ * because fields are only ever called with their own bank's questions.
98
+ */
99
+ getValue(question: Q): string | number | null | readonly string[];
64
100
  /**
65
101
  * Fixed options for select/multiselect. When omitted, options are derived
66
102
  * from distinct values across the bank's questions (sorted).
@@ -82,30 +118,157 @@ export interface BankSiteConfig {
82
118
  description?: string;
83
119
  }
84
120
 
85
- export interface DefineBankInput {
121
+ export interface DefineBankInput<Q extends BaseQuestion = BaseQuestion> {
86
122
  site: BankSiteConfig;
87
123
  /** Namespaces localStorage keys + Convex lookups. e.g. "comp20008". */
88
124
  namespace: string;
89
125
  /** Subject-specific schema. Built-ins (id/title/tags/status) are automatic. */
90
- fields: BankFieldDef[];
91
- questions: BaseQuestion[];
126
+ fields: BankFieldDef<Q>[];
127
+ questions: Q[];
92
128
  /** Suggested answers keyed by question id. Missing entries hide "Show answer". */
93
129
  answers?: Record<string, string>;
94
130
  /** Default sort for the grid, e.g. [{ id: "exam", desc: true }]. */
95
131
  defaultSorting?: Array<{ id: string; desc?: boolean }>;
132
+ /**
133
+ * Hide the built-in Tags column (and the Tags filter option). Useful when
134
+ * tags merely duplicate dedicated fields — e.g. every tag equals a
135
+ * topic/type value already shown in its own column. Tags still feed
136
+ * free-text search. Defaults to false.
137
+ */
138
+ hideTagsColumn?: boolean;
96
139
  /** Extra per-question figures, resolved by prompt `![](/figures/…)` etc. */
97
140
  publicPath?: string;
98
141
  }
99
142
 
100
- export interface Bank extends DefineBankInput {
143
+ export interface Bank extends DefineBankInput<BaseQuestion> {
101
144
  answers: Record<string, string>;
102
145
  questionById: Map<string, BaseQuestion>;
103
146
  }
104
147
 
105
- export function defineBank(input: DefineBankInput): Bank {
148
+ /**
149
+ * Build a bank. Q is inferred from `questions`, so each field's getValue is
150
+ * checked against the subject's question type — accessors use plain property
151
+ * access, no casts or coercion helpers.
152
+ */
153
+ export function defineBank<Q extends BaseQuestion>(input: DefineBankInput<Q>): Bank {
154
+ for (const question of input.questions) {
155
+ validateQuestion(question);
156
+ }
106
157
  return {
107
158
  ...input,
108
159
  answers: input.answers ?? {},
109
160
  questionById: new Map(input.questions.map((q) => [q.id, q])),
110
161
  };
111
162
  }
163
+
164
+ function validateQuestion(question: BaseQuestion): void {
165
+ const kind: QuestionKind = question.kind ?? 'written';
166
+ if (!questionKinds.includes(kind)) {
167
+ throw new Error(`Question "${question.id}" has unknown kind "${question.kind}".`);
168
+ }
169
+ if (kind === 'written') {
170
+ if (question.options !== undefined) {
171
+ throw new Error(`Question "${question.id}" is written and must not define options.`);
172
+ }
173
+ if (question.correctIds !== undefined) {
174
+ throw new Error(`Question "${question.id}" is written and must not define correctIds.`);
175
+ }
176
+ return;
177
+ }
178
+ const options = question.options ?? [];
179
+ if (options.length < 2) {
180
+ throw new Error(`Question "${question.id}" (${kind}) needs at least 2 options.`);
181
+ }
182
+ const seen = new Set<string>();
183
+ for (const option of options) {
184
+ if (!option.id || seen.has(option.id)) {
185
+ throw new Error(`Question "${question.id}" has a duplicate or empty option id "${option.id}".`);
186
+ }
187
+ seen.add(option.id);
188
+ }
189
+ const correctIds = question.correctIds ?? [];
190
+ if (kind === 'single' && correctIds.length !== 1) {
191
+ throw new Error(`Question "${question.id}" (single) needs exactly one correctId.`);
192
+ }
193
+ if (kind === 'multiple' && correctIds.length < 1) {
194
+ throw new Error(`Question "${question.id}" (multiple) needs at least one correctId.`);
195
+ }
196
+ for (const id of correctIds) {
197
+ if (!seen.has(id)) {
198
+ throw new Error(`Question "${question.id}" has unknown correctId "${id}".`);
199
+ }
200
+ }
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Choice helpers. All UI + scoring goes through these so option storage
205
+ // (ids) stays decoupled from presentation (letters, markdown).
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /** Effective kind: questions without `kind` are written. */
209
+ export function questionKind(question: BaseQuestion): QuestionKind {
210
+ return question.kind ?? 'written';
211
+ }
212
+
213
+ export function isChoiceQuestion(question: BaseQuestion): boolean {
214
+ const kind = questionKind(question);
215
+ return kind === 'single' || kind === 'multiple';
216
+ }
217
+
218
+ /** Correct option ids. Empty for written questions. */
219
+ export function getCorrectIds(question: BaseQuestion): string[] {
220
+ return isChoiceQuestion(question) ? [...(question.correctIds ?? [])] : [];
221
+ }
222
+
223
+ /** Normalize a stored answer to selected option ids — tolerates legacy strings. */
224
+ export function toSelectedIds(question: BaseQuestion, answer: QuizAnswer | undefined): string[] {
225
+ if (answer === undefined) return [];
226
+ const ids =
227
+ typeof answer === 'string' ? (answer === '' ? [] : [answer]) : answer.filter((id) => typeof id === 'string');
228
+ if (!isChoiceQuestion(question)) return ids;
229
+ const valid = new Set((question.options ?? []).map((o) => o.id));
230
+ return ids.filter((id) => valid.has(id));
231
+ }
232
+
233
+ /** Whether the user has provided an answer (non-blank text, or ≥1 selection). */
234
+ export function isAnswerProvided(question: BaseQuestion, answer: QuizAnswer | undefined): boolean {
235
+ if (answer === undefined) return false;
236
+ if (typeof answer === 'string') return answer.trim().length > 0;
237
+ if (!isChoiceQuestion(question)) return answer.some((id) => id.trim().length > 0);
238
+ const valid = new Set((question.options ?? []).map((o) => o.id));
239
+ return answer.some((id) => valid.has(id));
240
+ }
241
+
242
+ /**
243
+ * Grade a choice answer. Returns null for written questions or when
244
+ * unanswered. Single: exactly the correct id. Multiple: exact set match
245
+ * (order-insensitive, no extras, no omissions).
246
+ */
247
+ export function isAnswerCorrect(question: BaseQuestion, answer: QuizAnswer | undefined): boolean | null {
248
+ if (!isChoiceQuestion(question)) return null;
249
+ if (!isAnswerProvided(question, answer)) return null;
250
+ const selected = new Set(toSelectedIds(question, answer));
251
+ const correct = new Set(getCorrectIds(question));
252
+ if (selected.size !== correct.size) return false;
253
+ for (const id of selected) {
254
+ if (!correct.has(id)) return false;
255
+ }
256
+ return true;
257
+ }
258
+
259
+ /** Score a set of questions against answers: { correct, gradeable, answered }. */
260
+ export function scoreAnswers(
261
+ questions: readonly BaseQuestion[],
262
+ answers: Record<string, QuizAnswer>,
263
+ ): { correct: number; gradeable: number; answered: number } {
264
+ let correct = 0;
265
+ let gradeable = 0;
266
+ let answered = 0;
267
+ for (const question of questions) {
268
+ if (isAnswerProvided(question, answers[question.id])) answered += 1;
269
+ if (!isChoiceQuestion(question)) continue;
270
+ gradeable += 1;
271
+ if (isAnswerCorrect(question, answers[question.id]) === true) correct += 1;
272
+ }
273
+ return { correct, gradeable, answered };
274
+ }
@@ -17,14 +17,15 @@ import {
17
17
  } from '@tanstack/react-table';
18
18
  import { Search } from 'lucide-react';
19
19
  import { useBank } from '../../core/config.tsx';
20
- import type { BaseQuestion, QuestionStatus, QuestionStatusMap } from '../../core/types.ts';
20
+ import type { BaseQuestion, QuestionKind, QuestionStatus, QuestionStatusMap, QuizSession } from '../../core/types.ts';
21
+ import { isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
21
22
  import { Badge } from './ui/badge';
22
23
  import { Button } from './ui/button';
23
24
  import { Checkbox } from './ui/checkbox';
24
25
  import { Input } from './ui/input';
25
26
  import { FilterBuilder } from './filter-builder';
26
27
  import { MarkdownContent } from './markdown-content';
27
- import { Popover, PopoverAnchor, PopoverContent } from './ui/popover';
28
+ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './ui/popover';
28
29
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
29
30
  import {
30
31
  createFilterFields,
@@ -36,7 +37,7 @@ import {
36
37
  import { useQuestionFilterStore } from '../lib/question-filter-store.ts';
37
38
  import { cn } from '../lib/utils.ts';
38
39
  import { getQuestionStatuses } from '../lib/question-status-storage.ts';
39
- import { createQuiz } from '../lib/quiz-storage.ts';
40
+ import { createQuiz, listQuizzes, setQuizQuestions } from '../lib/quiz-storage.ts';
40
41
 
41
42
  type DragSelection = {
42
43
  value: boolean;
@@ -59,6 +60,14 @@ const questionStatusStyles: Record<QuestionStatus, string> = {
59
60
  complete: 'border-green-300 bg-green-50 text-green-800',
60
61
  };
61
62
 
63
+ const questionKindStyles: Record<QuestionKind, string> = {
64
+ written: 'border-gray-300 bg-gray-50 text-gray-700',
65
+ single: 'border-sky-300 bg-sky-50 text-sky-900',
66
+ multiple: 'border-violet-300 bg-violet-50 text-violet-900',
67
+ };
68
+
69
+ const optionLetter = (index: number): string => String.fromCharCode(65 + index);
70
+
62
71
  function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
63
72
  const [open, setOpen] = useState(false);
64
73
  const openTimeout = useRef<number | null>(null);
@@ -163,6 +172,18 @@ function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
163
172
  >
164
173
  <div className="px-4 py-3">
165
174
  <MarkdownContent content={question.prompt} />
175
+ {isChoiceQuestion(question) && (
176
+ <ol className="mt-3 space-y-1.5">
177
+ {(question.options ?? []).map((option, index) => (
178
+ <li key={option.id} className="flex items-start gap-2 text-sm text-gray-800">
179
+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-gray-50 text-[11px] font-bold text-gray-600">
180
+ {optionLetter(index)}
181
+ </span>
182
+ <MarkdownContent content={option.text} className="min-w-0 flex-1 text-sm leading-6" />
183
+ </li>
184
+ ))}
185
+ </ol>
186
+ )}
166
187
  </div>
167
188
  </PopoverContent>
168
189
  </Popover>
@@ -193,6 +214,10 @@ export function QuestionBank() {
193
214
  (bank.defaultSorting ?? [{ id: 'id' }]).map((s) => ({ id: s.id, desc: s.desc ?? false })),
194
215
  );
195
216
  const [creatingQuiz, setCreatingQuiz] = useState(false);
217
+ const [addToQuizOpen, setAddToQuizOpen] = useState(false);
218
+ const [existingQuizzes, setExistingQuizzes] = useState<QuizSession[] | null>(null);
219
+ const [addingToQuizId, setAddingToQuizId] = useState<string | null>(null);
220
+ const [addToQuizError, setAddToQuizError] = useState(false);
196
221
  const tableContainerRef = useRef<HTMLDivElement>(null);
197
222
  const rowSelectionRef = useRef(rowSelection);
198
223
  rowSelectionRef.current = rowSelection;
@@ -200,7 +225,18 @@ export function QuestionBank() {
200
225
  const pendingCheckboxChange = useRef<{ rowId: string; value: boolean } | null>(null);
201
226
  const suppressRowClick = useRef<string | null>(null);
202
227
 
203
- const filterFields = useMemo(() => createFilterFields(bank.questions, bank.fields), [bank]);
228
+ const filterFields = useMemo(
229
+ () => createFilterFields(bank.questions, bank.fields, bank.hideTagsColumn ?? false),
230
+ [bank],
231
+ );
232
+
233
+ // Size the ID column to the data: fits typical ids on one line, caps
234
+ // monsters (they wrap via break-all). Mono averages ~8.4px/char at
235
+ // text-sm, plus px-4 padding. e.g. econ ≈168, info hits the 320 cap.
236
+ const idWidth = useMemo(() => {
237
+ const longest = bank.questions.reduce((n, q) => Math.max(n, q.id.length), 0);
238
+ return Math.min(320, Math.max(120, Math.ceil(longest * 8.4 + 32)));
239
+ }, [bank.questions]);
204
240
 
205
241
  const handleAddFilter = (field: string) => {
206
242
  const type = filterFields.find((f) => f.id === field)?.type ?? 'text';
@@ -258,7 +294,7 @@ export function QuestionBank() {
258
294
  </div>
259
295
  );
260
296
  }
261
- return <span className="text-sm text-foreground">{formatCellValue(raw)}</span>;
297
+ return <span className="whitespace-normal break-words text-sm text-foreground">{formatCellValue(raw)}</span>;
262
298
  },
263
299
  enableSorting: sortable,
264
300
  size: width,
@@ -314,28 +350,58 @@ export function QuestionBank() {
314
350
  {
315
351
  accessorKey: 'id',
316
352
  header: 'ID',
317
- cell: ({ row }) => <span className="font-mono text-sm text-muted-foreground">{row.original.id}</span>,
318
- size: 104,
353
+ cell: ({ row }) => (
354
+ <span className="whitespace-normal break-all font-mono text-sm text-muted-foreground">{row.original.id}</span>
355
+ ),
356
+ size: idWidth,
319
357
  },
320
358
  {
359
+ // Sole absorbent column: no fixed size, so it takes up slack on wide
360
+ // screens instead of leaving dead space. Table min-width (below)
361
+ // always reserves 300px for it, so it can never collapse — and text
362
+ // wraps, so it can never paint over neighbours either.
321
363
  accessorKey: 'title',
322
364
  header: 'Question',
323
365
  cell: ({ row }) => <QuestionPreviewPopover question={row.original} />,
324
- size: 340,
325
366
  },
367
+ // Absorbent like title: no fixed size, takes up slack on wide screens.
368
+ // minTableWidth (below) always reserves 200px for it, so it can
369
+ // never collapse to zero the way it did when the fixed columns
370
+ // already summed past the old 1200px floor.
371
+ // Hidden entirely when the bank sets hideTagsColumn (tags that merely
372
+ // duplicate dedicated fields).
373
+ ...(bank.hideTagsColumn
374
+ ? []
375
+ : [
376
+ {
377
+ id: 'tags',
378
+ accessorFn: (row) => row.tags.join(', '),
379
+ header: 'Tags',
380
+ cell: ({ row }) => (
381
+ <div className="flex min-w-0 flex-wrap gap-1">
382
+ {row.original.tags.map((tag) => (
383
+ <Badge key={tag} variant="secondary" className="min-w-0 whitespace-normal break-words">
384
+ {tag}
385
+ </Badge>
386
+ ))}
387
+ </div>
388
+ ),
389
+ } satisfies ColumnDef<BaseQuestion>,
390
+ ]),
326
391
  {
327
- id: 'tags',
328
- accessorFn: (row) => row.tags.join(', '),
329
- header: 'Tags',
330
- cell: ({ row }) => (
331
- <div className="flex flex-wrap gap-1">
332
- {row.original.tags.map((tag) => (
333
- <Badge key={tag} variant="secondary">
334
- {tag}
335
- </Badge>
336
- ))}
337
- </div>
338
- ),
392
+ id: 'kind',
393
+ accessorFn: (row) => questionKind(row),
394
+ header: 'Type',
395
+ cell: ({ row }) => {
396
+ const kind = questionKind(row.original);
397
+ const count = row.original.options?.length ?? 0;
398
+ return (
399
+ <Badge variant="outline" className={questionKindStyles[kind]} title={count > 0 ? `${count} options` : undefined}>
400
+ {questionKindLabels[kind]}
401
+ </Badge>
402
+ );
403
+ },
404
+ size: 132,
339
405
  },
340
406
  ...customs,
341
407
  {
@@ -353,7 +419,22 @@ export function QuestionBank() {
353
419
  size: 108,
354
420
  },
355
421
  ];
356
- }, [bank.fields, questionStatusMap]);
422
+ }, [bank.fields, bank.hideTagsColumn, idWidth, questionStatusMap]);
423
+
424
+ // Floor the table width at the sum of fixed columns plus 300px for the
425
+ // absorbent title and 200px for the absorbent tags, so neither can ever
426
+ // collapse to zero (which squeezed content over neighbouring columns).
427
+ const minTableWidth = useMemo(() => {
428
+ let total = 0;
429
+ for (const column of columns) {
430
+ // ColumnDef inputs use accessorKey instead of id for the id/title columns.
431
+ const key = column.id ?? (column as { accessorKey?: string }).accessorKey;
432
+ if (key === 'title') total += 300;
433
+ else if (key === 'tags') total += 200;
434
+ else total += column.size ?? 150;
435
+ }
436
+ return Math.max(1200, total);
437
+ }, [columns]);
357
438
 
358
439
  const table = useReactTable({
359
440
  data: filteredQuestions,
@@ -512,6 +593,32 @@ export function QuestionBank() {
512
593
  }
513
594
  };
514
595
 
596
+ const openAddToQuiz = (open: boolean) => {
597
+ setAddToQuizOpen(open);
598
+ if (!open) return;
599
+ setExistingQuizzes(null);
600
+ setAddToQuizError(false);
601
+ listQuizzes(bank.namespace).then(setExistingQuizzes, () => setExistingQuizzes([]));
602
+ };
603
+
604
+ const addToQuiz = async (quiz: QuizSession) => {
605
+ const existing = new Set(quiz.questionIds);
606
+ const additions = bank.questions.filter((question) => rowSelection[question.id] && !existing.has(question.id));
607
+ setAddingToQuizId(quiz.id);
608
+ setAddToQuizError(false);
609
+ try {
610
+ if (additions.length > 0) {
611
+ await setQuizQuestions(bank.namespace, quiz, [...quiz.questionIds, ...additions.map((question) => question.id)]);
612
+ }
613
+ setAddToQuizOpen(false);
614
+ await navigate({ to: '/quiz/$quizId', params: { quizId: quiz.id } });
615
+ } catch {
616
+ setAddToQuizError(true);
617
+ } finally {
618
+ setAddingToQuizId(null);
619
+ }
620
+ };
621
+
515
622
  return (
516
623
  <div className="flex min-h-0 flex-1 flex-col">
517
624
  <div className="border-b bg-background px-4 py-3">
@@ -544,14 +651,16 @@ export function QuestionBank() {
544
651
  </div>
545
652
  </div>
546
653
 
547
- <Table containerRef={tableContainerRef} containerClassName="min-h-0 flex-1 overflow-auto" className="min-w-[1200px] table-fixed">
654
+ <Table containerRef={tableContainerRef} containerClassName="min-h-0 flex-1 overflow-auto" className="table-fixed" style={{ minWidth: minTableWidth }}>
548
655
  <TableHeader>
549
656
  {table.getHeaderGroups().map((headerGroup) => (
550
657
  <TableRow key={headerGroup.id}>
551
658
  {headerGroup.headers.map((header) => (
552
659
  <TableHead
553
660
  key={header.id}
554
- style={header.column.id === 'tags' ? undefined : { width: header.getSize() }}
661
+ // Title and tags are the absorbent columns (no width → take up
662
+ // slack on wide screens). minTableWidth below floors them.
663
+ style={header.column.id === 'tags' || header.column.id === 'title' ? undefined : { width: header.getSize() }}
555
664
  className="sticky top-0 z-10 h-12 border-b bg-muted px-4 text-sm font-semibold text-foreground"
556
665
  >
557
666
  {header.isPlaceholder ? null : header.column.getCanSort() ? (
@@ -598,7 +707,10 @@ export function QuestionBank() {
598
707
  {row.getVisibleCells().map((cell) => (
599
708
  <TableCell
600
709
  key={cell.id}
601
- className={cn('border-b px-4 py-2 align-middle', cell.column.id === 'title' && 'whitespace-normal')}
710
+ className={cn(
711
+ 'border-b px-4 py-2 align-middle',
712
+ cell.column.id === 'title' && 'whitespace-normal break-words',
713
+ )}
602
714
  onClick={cell.column.id === 'select' ? (event) => event.stopPropagation() : undefined}
603
715
  >
604
716
  {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -642,7 +754,51 @@ export function QuestionBank() {
642
754
  <Button variant="ghost" className="text-base" onClick={() => setRowSelection({})}>
643
755
  Clear
644
756
  </Button>
645
- <div className="ml-auto">
757
+ <div className="ml-auto flex gap-2">
758
+ <Popover open={addToQuizOpen} onOpenChange={openAddToQuiz}>
759
+ <PopoverTrigger asChild>
760
+ <Button variant="outline" className="text-base">
761
+ Add to quiz
762
+ </Button>
763
+ </PopoverTrigger>
764
+ <PopoverContent side="top" align="end" className="w-80 p-1">
765
+ {existingQuizzes === null ? (
766
+ <p className="m-0 px-3 py-4 text-center text-sm text-muted-foreground">Loading quizzes...</p>
767
+ ) : existingQuizzes.length === 0 ? (
768
+ <p className="m-0 px-3 py-4 text-center text-sm text-muted-foreground">No quizzes yet. Use "Make quiz" instead.</p>
769
+ ) : (
770
+ <ul className="m-0 max-h-72 list-none overflow-y-auto p-0">
771
+ {existingQuizzes.map((quiz) => {
772
+ const newCount = selectedIds.filter((id) => !quiz.questionIds.includes(id)).length;
773
+ return (
774
+ <li key={quiz.id}>
775
+ <button
776
+ type="button"
777
+ disabled={addingToQuizId !== null}
778
+ onClick={() => addToQuiz(quiz)}
779
+ className="flex w-full flex-col items-start rounded-sm px-3 py-2 text-left hover:bg-muted focus-visible:bg-muted focus-visible:outline-none disabled:opacity-50"
780
+ >
781
+ <span className="text-sm font-medium">{quiz.title}</span>
782
+ <span className="text-xs text-muted-foreground">
783
+ {addingToQuizId === quiz.id
784
+ ? 'Adding...'
785
+ : `${quiz.questionIds.length} ${quiz.questionIds.length === 1 ? 'question' : 'questions'} • ${
786
+ newCount === 0 ? 'all selected already included' : `+${newCount} new`
787
+ }`}
788
+ </span>
789
+ </button>
790
+ </li>
791
+ );
792
+ })}
793
+ </ul>
794
+ )}
795
+ {addToQuizError && (
796
+ <p role="alert" className="m-0 px-3 py-2 text-sm text-red-700">
797
+ Could not add to that quiz. Please try again.
798
+ </p>
799
+ )}
800
+ </PopoverContent>
801
+ </Popover>
646
802
  <Button className="text-base" onClick={makeQuiz} disabled={creatingQuiz}>
647
803
  {creatingQuiz ? 'Making quiz...' : 'Make quiz'}
648
804
  </Button>