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 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
@@ -8,11 +8,41 @@ export const questionStatuses = ['unanswered', 'revisit', 'complete'] as const;
8
8
  export type QuestionStatus = (typeof questionStatuses)[number];
9
9
  export type QuestionStatusMap = Record<string, QuestionStatus>;
10
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
+
11
35
  export interface BaseQuestion {
12
36
  id: string;
13
37
  title: string;
14
38
  prompt: string;
15
39
  tags: string[];
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[];
16
46
  // Subjects extend this interface with their own fields; see template/bank.config.ts.
17
47
  }
18
48
 
@@ -20,7 +50,7 @@ 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;
@@ -99,6 +129,13 @@ export interface DefineBankInput<Q extends BaseQuestion = BaseQuestion> {
99
129
  answers?: Record<string, string>;
100
130
  /** Default sort for the grid, e.g. [{ id: "exam", desc: true }]. */
101
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;
102
139
  /** Extra per-question figures, resolved by prompt `![](/figures/…)` etc. */
103
140
  publicPath?: string;
104
141
  }
@@ -114,9 +151,124 @@ export interface Bank extends DefineBankInput<BaseQuestion> {
114
151
  * access, no casts or coercion helpers.
115
152
  */
116
153
  export function defineBank<Q extends BaseQuestion>(input: DefineBankInput<Q>): Bank {
154
+ for (const question of input.questions) {
155
+ validateQuestion(question);
156
+ }
117
157
  return {
118
158
  ...input,
119
159
  answers: input.answers ?? {},
120
160
  questionById: new Map(input.questions.map((q) => [q.id, q])),
121
161
  };
122
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>