question-bank-framework 0.8.0 → 0.10.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 -8
- package/framework/components/app-shell.tsx +11 -7
- package/framework/components/exam-timer.tsx +64 -33
- package/framework/components/filter-builder.tsx +65 -63
- package/framework/components/markdown-content.tsx +15 -15
- package/framework/components/question-bank.tsx +150 -227
- package/framework/components/question-response.tsx +41 -31
- package/framework/components/quiz-list.tsx +42 -37
- package/framework/components/quiz-page.tsx +63 -62
- package/framework/components/quiz-question-editor.tsx +11 -11
- package/framework/components/ui/badge.tsx +29 -23
- package/framework/components/ui/button.tsx +24 -28
- package/framework/components/ui/card.tsx +104 -0
- package/framework/components/ui/checkbox.tsx +9 -10
- package/framework/components/ui/dropdown-menu.tsx +139 -123
- package/framework/components/ui/field.tsx +238 -0
- package/framework/components/ui/input.tsx +6 -5
- package/framework/components/ui/kbd.tsx +28 -0
- package/framework/components/ui/label.tsx +19 -0
- package/framework/components/ui/popover.tsx +35 -32
- package/framework/components/ui/select.tsx +80 -65
- package/framework/components/ui/separator.tsx +24 -0
- package/framework/components/ui/sheet.tsx +36 -39
- package/framework/components/ui/table.tsx +4 -4
- package/framework/components/ui/toast.tsx +2 -2
- package/framework/components/ui/tooltip.tsx +65 -0
- package/framework/index.ts +1 -1
- package/framework/lib/exam-timer.ts +29 -10
- package/framework/styles/theme.css +66 -28
- package/package.json +3 -1
- package/template/src/routes/__root.tsx +1 -1
- package/template/src/routes/styles.css +0 -4
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
type SortingFn,
|
|
17
17
|
type SortingState,
|
|
18
18
|
} from '@tanstack/react-table';
|
|
19
|
-
import { Search } from 'lucide-react';
|
|
19
|
+
import { ArrowDown, ArrowUp, ChevronsUpDown, Search } from 'lucide-react';
|
|
20
20
|
import { useBank } from '../../core/config.tsx';
|
|
21
|
-
import type { BaseQuestion,
|
|
21
|
+
import type { BaseQuestion, QuestionStatus, QuestionStatusMap, QuizSession } from '../../core/types.ts';
|
|
22
22
|
import { getPairingSlots, isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
|
|
23
23
|
import { Badge } from './ui/badge';
|
|
24
24
|
import { Button } from './ui/button';
|
|
@@ -26,7 +26,7 @@ import { Checkbox } from './ui/checkbox';
|
|
|
26
26
|
import { Input } from './ui/input';
|
|
27
27
|
import { FilterBuilder } from './filter-builder';
|
|
28
28
|
import { MarkdownContent } from './markdown-content';
|
|
29
|
-
import { Popover,
|
|
29
|
+
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
|
30
30
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
|
31
31
|
import {
|
|
32
32
|
createFilterFields,
|
|
@@ -60,19 +60,10 @@ const naturalSort: SortingFn<BaseQuestion> = (rowA, rowB, columnId) =>
|
|
|
60
60
|
|
|
61
61
|
const previewCloseDelay = 180;
|
|
62
62
|
const previewOpenDelay = 400;
|
|
63
|
-
const
|
|
64
|
-
unanswered: '
|
|
65
|
-
revisit: '
|
|
66
|
-
complete: '
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const questionKindStyles: Record<QuestionKind, string> = {
|
|
70
|
-
written: 'border-gray-300 bg-gray-50 text-gray-700',
|
|
71
|
-
single: 'border-sky-300 bg-sky-50 text-sky-900',
|
|
72
|
-
multiple: 'border-violet-300 bg-violet-50 text-violet-900',
|
|
73
|
-
numerical: 'border-emerald-300 bg-emerald-50 text-emerald-900',
|
|
74
|
-
matching: 'border-orange-300 bg-orange-50 text-orange-900',
|
|
75
|
-
dropdown: 'border-rose-300 bg-rose-50 text-rose-900',
|
|
63
|
+
const questionStatusDot: Record<QuestionStatus, string> = {
|
|
64
|
+
unanswered: 'bg-muted-foreground/40',
|
|
65
|
+
revisit: 'bg-warning',
|
|
66
|
+
complete: 'bg-success',
|
|
76
67
|
};
|
|
77
68
|
|
|
78
69
|
const optionLetter = (index: number): string => String.fromCharCode(65 + index);
|
|
@@ -95,132 +86,50 @@ function kindDetail(question: BaseQuestion): string | undefined {
|
|
|
95
86
|
}
|
|
96
87
|
|
|
97
88
|
function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
|
|
98
|
-
const
|
|
99
|
-
const openTimeout = useRef<number | null>(null);
|
|
100
|
-
const closeTimeout = useRef<number | null>(null);
|
|
101
|
-
const openedByPointer = useRef(false);
|
|
102
|
-
|
|
103
|
-
const clearOpenTimeout = () => {
|
|
104
|
-
if (openTimeout.current !== null) {
|
|
105
|
-
window.clearTimeout(openTimeout.current);
|
|
106
|
-
openTimeout.current = null;
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
const clearCloseTimeout = () => {
|
|
110
|
-
if (closeTimeout.current !== null) {
|
|
111
|
-
window.clearTimeout(closeTimeout.current);
|
|
112
|
-
closeTimeout.current = null;
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
const openPreview = () => {
|
|
116
|
-
clearOpenTimeout();
|
|
117
|
-
clearCloseTimeout();
|
|
118
|
-
setOpen(true);
|
|
119
|
-
};
|
|
120
|
-
const scheduleOpen = () => {
|
|
121
|
-
clearOpenTimeout();
|
|
122
|
-
clearCloseTimeout();
|
|
123
|
-
openTimeout.current = window.setTimeout(() => {
|
|
124
|
-
setOpen(true);
|
|
125
|
-
openTimeout.current = null;
|
|
126
|
-
}, previewOpenDelay);
|
|
127
|
-
};
|
|
128
|
-
const scheduleClose = () => {
|
|
129
|
-
clearOpenTimeout();
|
|
130
|
-
clearCloseTimeout();
|
|
131
|
-
closeTimeout.current = window.setTimeout(() => {
|
|
132
|
-
setOpen(false);
|
|
133
|
-
openedByPointer.current = false;
|
|
134
|
-
closeTimeout.current = null;
|
|
135
|
-
}, previewCloseDelay);
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
useEffect(
|
|
139
|
-
() => () => {
|
|
140
|
-
if (openTimeout.current !== null) window.clearTimeout(openTimeout.current);
|
|
141
|
-
if (closeTimeout.current !== null) window.clearTimeout(closeTimeout.current);
|
|
142
|
-
},
|
|
143
|
-
[],
|
|
144
|
-
);
|
|
145
|
-
|
|
89
|
+
const pairingSlots = getPairingSlots(question);
|
|
146
90
|
return (
|
|
147
|
-
<Popover
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}}
|
|
159
|
-
>
|
|
160
|
-
<PopoverAnchor asChild>
|
|
161
|
-
<button
|
|
162
|
-
type="button"
|
|
163
|
-
aria-haspopup="dialog"
|
|
164
|
-
aria-expanded={open}
|
|
165
|
-
aria-label={`Preview question: ${question.title}`}
|
|
166
|
-
className="block max-w-full rounded-sm text-left text-base font-semibold leading-6 text-foreground hover:underline hover:decoration-dotted hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
167
|
-
onPointerEnter={() => {
|
|
168
|
-
openedByPointer.current = true;
|
|
169
|
-
scheduleOpen();
|
|
170
|
-
}}
|
|
171
|
-
onPointerLeave={scheduleClose}
|
|
172
|
-
onFocus={() => {
|
|
173
|
-
if (!openedByPointer.current) scheduleOpen();
|
|
174
|
-
}}
|
|
175
|
-
onBlur={scheduleClose}
|
|
176
|
-
onClick={(event) => {
|
|
177
|
-
event.stopPropagation();
|
|
178
|
-
scheduleOpen();
|
|
179
|
-
}}
|
|
180
|
-
>
|
|
181
|
-
{question.title}
|
|
182
|
-
</button>
|
|
183
|
-
</PopoverAnchor>
|
|
91
|
+
<Popover>
|
|
92
|
+
<PopoverTrigger
|
|
93
|
+
openOnHover
|
|
94
|
+
delay={previewOpenDelay}
|
|
95
|
+
closeDelay={previewCloseDelay}
|
|
96
|
+
aria-label={`Preview question: ${question.title}`}
|
|
97
|
+
onClick={(event) => event.stopPropagation()}
|
|
98
|
+
className="block max-w-full rounded-sm text-left text-sm font-medium text-foreground decoration-muted-foreground/50 underline-offset-4 hover:underline focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
99
|
+
>
|
|
100
|
+
{question.title}
|
|
101
|
+
</PopoverTrigger>
|
|
184
102
|
<PopoverContent
|
|
185
103
|
side="right"
|
|
186
104
|
align="start"
|
|
187
105
|
sideOffset={8}
|
|
188
106
|
collisionPadding={12}
|
|
189
107
|
aria-label={`Preview: ${question.title}`}
|
|
190
|
-
className="max-h-[min(70dvh,calc(100dvh-1.5rem))] w-[min(
|
|
191
|
-
onOpenAutoFocus={(event) => {
|
|
192
|
-
if (openedByPointer.current) event.preventDefault();
|
|
193
|
-
}}
|
|
194
|
-
onPointerEnter={openPreview}
|
|
195
|
-
onPointerLeave={scheduleClose}
|
|
196
|
-
onFocusCapture={openPreview}
|
|
197
|
-
onBlurCapture={scheduleClose}
|
|
108
|
+
className="max-h-[min(70dvh,calc(100dvh-1.5rem))] w-[min(640px,calc(100vw-2rem))] gap-0 overflow-y-auto p-4"
|
|
198
109
|
>
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
{
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
{
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
110
|
+
<MarkdownContent content={question.prompt} />
|
|
111
|
+
{isChoiceQuestion(question) && (
|
|
112
|
+
<ol className="mt-3 grid gap-1.5">
|
|
113
|
+
{(question.options ?? []).map((option, index) => (
|
|
114
|
+
<li key={option.id} className="flex items-start gap-2.5 text-sm">
|
|
115
|
+
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium text-muted-foreground tabular-nums">
|
|
116
|
+
{optionLetter(index)}
|
|
117
|
+
</span>
|
|
118
|
+
<MarkdownContent content={option.text} className="min-w-0 flex-1 text-sm leading-6" />
|
|
119
|
+
</li>
|
|
120
|
+
))}
|
|
121
|
+
</ol>
|
|
122
|
+
)}
|
|
123
|
+
{pairingSlots.length > 0 && (
|
|
124
|
+
<ol className="mt-3 list-decimal space-y-1 pl-6 text-sm marker:text-muted-foreground">
|
|
125
|
+
{pairingSlots.map((slot, index) => (
|
|
126
|
+
<li key={slot.id}>
|
|
127
|
+
<MarkdownContent inline content={slot.label?.trim() ? slot.label : `Blank ${index + 1}`} />
|
|
128
|
+
<span className="text-muted-foreground"> → ___</span>
|
|
129
|
+
</li>
|
|
130
|
+
))}
|
|
131
|
+
</ol>
|
|
132
|
+
)}
|
|
224
133
|
</PopoverContent>
|
|
225
134
|
</Popover>
|
|
226
135
|
);
|
|
@@ -267,11 +176,11 @@ export function QuestionBank() {
|
|
|
267
176
|
);
|
|
268
177
|
|
|
269
178
|
// Size the ID column to the data: fits typical ids on one line, caps
|
|
270
|
-
// monsters (they wrap via break-all). Mono averages ~
|
|
271
|
-
// text-
|
|
179
|
+
// monsters (they wrap via break-all). Mono averages ~7.3px/char at
|
|
180
|
+
// text-xs, plus px-3 padding each side.
|
|
272
181
|
const idWidth = useMemo(() => {
|
|
273
182
|
const longest = bank.questions.reduce((n, q) => Math.max(n, q.id.length), 0);
|
|
274
|
-
return Math.min(
|
|
183
|
+
return Math.min(280, Math.max(96, Math.ceil(longest * 7.3 + 24)));
|
|
275
184
|
}, [bank.questions]);
|
|
276
185
|
|
|
277
186
|
const handleAddFilter = (field: string) => {
|
|
@@ -323,14 +232,14 @@ export function QuestionBank() {
|
|
|
323
232
|
return (
|
|
324
233
|
<div className="flex flex-wrap gap-1">
|
|
325
234
|
{(raw as readonly string[]).map((tag) => (
|
|
326
|
-
<Badge key={String(tag)} variant="secondary">
|
|
235
|
+
<Badge key={String(tag)} variant="secondary" className="h-auto rounded-md font-normal break-words whitespace-normal">
|
|
327
236
|
{String(tag)}
|
|
328
237
|
</Badge>
|
|
329
238
|
))}
|
|
330
239
|
</div>
|
|
331
240
|
);
|
|
332
241
|
}
|
|
333
|
-
return <span className="
|
|
242
|
+
return <span className="text-sm break-words whitespace-normal text-muted-foreground">{formatCellValue(raw)}</span>;
|
|
334
243
|
},
|
|
335
244
|
enableSorting: sortable,
|
|
336
245
|
...(def.type === 'number' ? {} : { sortingFn: naturalSort }),
|
|
@@ -344,8 +253,9 @@ export function QuestionBank() {
|
|
|
344
253
|
header: ({ table }) => (
|
|
345
254
|
<Checkbox
|
|
346
255
|
aria-label="Select all matching questions"
|
|
347
|
-
checked={table.getIsAllPageRowsSelected()
|
|
348
|
-
|
|
256
|
+
checked={table.getIsAllPageRowsSelected()}
|
|
257
|
+
indeterminate={!table.getIsAllPageRowsSelected() && table.getIsSomePageRowsSelected()}
|
|
258
|
+
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
|
|
349
259
|
/>
|
|
350
260
|
),
|
|
351
261
|
cell: ({ row }) => (
|
|
@@ -382,13 +292,13 @@ export function QuestionBank() {
|
|
|
382
292
|
/>
|
|
383
293
|
),
|
|
384
294
|
enableSorting: false,
|
|
385
|
-
size:
|
|
295
|
+
size: 48,
|
|
386
296
|
},
|
|
387
297
|
{
|
|
388
298
|
accessorKey: 'id',
|
|
389
299
|
header: 'ID',
|
|
390
300
|
cell: ({ row }) => (
|
|
391
|
-
<span className="
|
|
301
|
+
<span className="font-mono text-xs break-all whitespace-normal text-muted-foreground">{row.original.id}</span>
|
|
392
302
|
),
|
|
393
303
|
sortingFn: naturalSort,
|
|
394
304
|
size: idWidth,
|
|
@@ -420,7 +330,7 @@ export function QuestionBank() {
|
|
|
420
330
|
cell: ({ row }) => (
|
|
421
331
|
<div className="flex min-w-0 flex-wrap gap-1">
|
|
422
332
|
{row.original.tags.map((tag) => (
|
|
423
|
-
<Badge key={tag} variant="secondary" className="min-w-0
|
|
333
|
+
<Badge key={tag} variant="secondary" className="h-auto min-w-0 rounded-md font-normal break-words whitespace-normal">
|
|
424
334
|
{tag}
|
|
425
335
|
</Badge>
|
|
426
336
|
))}
|
|
@@ -435,7 +345,7 @@ export function QuestionBank() {
|
|
|
435
345
|
cell: ({ row }) => {
|
|
436
346
|
const kind = questionKind(row.original);
|
|
437
347
|
return (
|
|
438
|
-
<Badge variant="outline" className=
|
|
348
|
+
<Badge variant="outline" className="font-normal text-muted-foreground" title={kindDetail(row.original)}>
|
|
439
349
|
{questionKindLabels[kind]}
|
|
440
350
|
</Badge>
|
|
441
351
|
);
|
|
@@ -450,9 +360,10 @@ export function QuestionBank() {
|
|
|
450
360
|
cell: ({ row }) => {
|
|
451
361
|
const status = questionStatusMap[row.original.id] ?? 'unanswered';
|
|
452
362
|
return (
|
|
453
|
-
<
|
|
363
|
+
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
|
364
|
+
<span aria-hidden="true" className={cn('size-1.5 shrink-0 rounded-full', questionStatusDot[status])} />
|
|
454
365
|
{questionStatusLabels[status]}
|
|
455
|
-
</
|
|
366
|
+
</span>
|
|
456
367
|
);
|
|
457
368
|
},
|
|
458
369
|
size: 108,
|
|
@@ -668,59 +579,78 @@ export function QuestionBank() {
|
|
|
668
579
|
}
|
|
669
580
|
};
|
|
670
581
|
|
|
582
|
+
const resultCount = tableRows.length;
|
|
583
|
+
|
|
671
584
|
return (
|
|
672
585
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
673
|
-
<div className="border-b
|
|
674
|
-
<
|
|
675
|
-
<
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
/>
|
|
684
|
-
</label>
|
|
685
|
-
<FilterBuilder
|
|
686
|
-
fields={filterFields}
|
|
687
|
-
filters={filters}
|
|
688
|
-
match={filterMatch}
|
|
689
|
-
onAdd={handleAddFilter}
|
|
690
|
-
onChange={updateFilter}
|
|
691
|
-
onRemove={removeFilter}
|
|
692
|
-
onClear={clearFilters}
|
|
693
|
-
onMatchChange={setFilterMatch}
|
|
586
|
+
<div className="flex flex-wrap items-center gap-2 border-b px-6 py-3">
|
|
587
|
+
<label className="relative w-full max-w-sm min-w-60 flex-1 sm:flex-none">
|
|
588
|
+
<Search aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
589
|
+
<Input
|
|
590
|
+
type="search"
|
|
591
|
+
value={search}
|
|
592
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
593
|
+
placeholder="Search"
|
|
594
|
+
aria-label="Search questions"
|
|
595
|
+
className="pl-8"
|
|
694
596
|
/>
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
597
|
+
</label>
|
|
598
|
+
<FilterBuilder
|
|
599
|
+
fields={filterFields}
|
|
600
|
+
filters={filters}
|
|
601
|
+
match={filterMatch}
|
|
602
|
+
onAdd={handleAddFilter}
|
|
603
|
+
onChange={updateFilter}
|
|
604
|
+
onRemove={removeFilter}
|
|
605
|
+
onClear={clearFilters}
|
|
606
|
+
onMatchChange={setFilterMatch}
|
|
607
|
+
/>
|
|
608
|
+
{filters.length > 0 && (
|
|
609
|
+
<Button variant="ghost" onClick={clearFilters}>
|
|
610
|
+
Clear filters
|
|
611
|
+
</Button>
|
|
612
|
+
)}
|
|
613
|
+
<p className="ml-auto text-xs text-muted-foreground tabular-nums" aria-live="polite">
|
|
614
|
+
{resultCount} {resultCount === 1 ? 'question' : 'questions'}
|
|
615
|
+
</p>
|
|
701
616
|
</div>
|
|
702
617
|
|
|
703
|
-
<Table
|
|
618
|
+
<Table
|
|
619
|
+
containerRef={tableContainerRef}
|
|
620
|
+
containerClassName="min-h-0 flex-1 overflow-auto"
|
|
621
|
+
className="table-fixed"
|
|
622
|
+
style={{ minWidth: minTableWidth }}
|
|
623
|
+
>
|
|
704
624
|
<TableHeader>
|
|
705
625
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
706
|
-
<TableRow key={headerGroup.id}>
|
|
707
|
-
{headerGroup.headers.map((header) =>
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
626
|
+
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
|
627
|
+
{headerGroup.headers.map((header) => {
|
|
628
|
+
const sorted = header.column.getIsSorted();
|
|
629
|
+
const SortIcon = sorted === 'asc' ? ArrowUp : sorted === 'desc' ? ArrowDown : ChevronsUpDown;
|
|
630
|
+
return (
|
|
631
|
+
<TableHead
|
|
632
|
+
key={header.id}
|
|
633
|
+
// Title and tags are the absorbent columns (no width → take up
|
|
634
|
+
// slack on wide screens). minTableWidth floors them.
|
|
635
|
+
style={header.column.id === 'tags' || header.column.id === 'title' ? undefined : { width: header.getSize() }}
|
|
636
|
+
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined}
|
|
637
|
+
className="sticky top-0 z-10 h-9 bg-background px-3 text-xs font-medium text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] first:pl-6 last:pr-6"
|
|
638
|
+
>
|
|
639
|
+
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
640
|
+
<button
|
|
641
|
+
type="button"
|
|
642
|
+
className="-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
643
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
644
|
+
>
|
|
645
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
646
|
+
<SortIcon aria-hidden="true" className={cn('size-3.5', !sorted && 'opacity-40')} />
|
|
647
|
+
</button>
|
|
648
|
+
) : (
|
|
649
|
+
flexRender(header.column.columnDef.header, header.getContext())
|
|
650
|
+
)}
|
|
651
|
+
</TableHead>
|
|
652
|
+
);
|
|
653
|
+
})}
|
|
724
654
|
</TableRow>
|
|
725
655
|
))}
|
|
726
656
|
</TableHeader>
|
|
@@ -728,8 +658,8 @@ export function QuestionBank() {
|
|
|
728
658
|
{tableRows.length > 0 ? (
|
|
729
659
|
<>
|
|
730
660
|
{topPadding > 0 && (
|
|
731
|
-
<TableRow aria-hidden="true" className="border-0">
|
|
732
|
-
<TableCell colSpan={columns.length} className="
|
|
661
|
+
<TableRow aria-hidden="true" className="border-0 hover:bg-transparent">
|
|
662
|
+
<TableCell colSpan={columns.length} className="p-0" style={{ height: topPadding }} />
|
|
733
663
|
</TableRow>
|
|
734
664
|
)}
|
|
735
665
|
{virtualRows.map((virtualRow) => {
|
|
@@ -743,7 +673,7 @@ export function QuestionBank() {
|
|
|
743
673
|
data-virtual-index={virtualRow.index}
|
|
744
674
|
data-question-row={row.id}
|
|
745
675
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
746
|
-
className="
|
|
676
|
+
className="cursor-pointer select-none"
|
|
747
677
|
onPointerEnter={() => updateDragRange(virtualRow.index)}
|
|
748
678
|
onClick={() => {
|
|
749
679
|
if (suppressRowClick.current === row.id) {
|
|
@@ -757,8 +687,8 @@ export function QuestionBank() {
|
|
|
757
687
|
<TableCell
|
|
758
688
|
key={cell.id}
|
|
759
689
|
className={cn(
|
|
760
|
-
'
|
|
761
|
-
cell.column.id === 'title' && 'whitespace-normal
|
|
690
|
+
'px-3 py-2.5 align-middle first:pl-6 last:pr-6',
|
|
691
|
+
cell.column.id === 'title' && 'break-words whitespace-normal',
|
|
762
692
|
)}
|
|
763
693
|
onClick={cell.column.id === 'select' ? (event) => event.stopPropagation() : undefined}
|
|
764
694
|
>
|
|
@@ -769,16 +699,15 @@ export function QuestionBank() {
|
|
|
769
699
|
);
|
|
770
700
|
})}
|
|
771
701
|
{bottomPadding > 0 && (
|
|
772
|
-
<TableRow aria-hidden="true" className="border-0">
|
|
773
|
-
<TableCell colSpan={columns.length} className="
|
|
702
|
+
<TableRow aria-hidden="true" className="border-0 hover:bg-transparent">
|
|
703
|
+
<TableCell colSpan={columns.length} className="p-0" style={{ height: bottomPadding }} />
|
|
774
704
|
</TableRow>
|
|
775
705
|
)}
|
|
776
706
|
</>
|
|
777
707
|
) : (
|
|
778
|
-
<TableRow>
|
|
779
|
-
<TableCell colSpan={columns.length} className="h-
|
|
780
|
-
<p className="text-
|
|
781
|
-
<p className="mt-1 text-base text-muted-foreground">Try another search or clear your filters.</p>
|
|
708
|
+
<TableRow className="hover:bg-transparent">
|
|
709
|
+
<TableCell colSpan={columns.length} className="h-48 text-center whitespace-normal">
|
|
710
|
+
<p className="text-sm text-muted-foreground">No matching questions</p>
|
|
782
711
|
<Button
|
|
783
712
|
variant="outline"
|
|
784
713
|
className="mt-4"
|
|
@@ -796,27 +725,21 @@ export function QuestionBank() {
|
|
|
796
725
|
</Table>
|
|
797
726
|
|
|
798
727
|
{selectedIds.length > 0 && (
|
|
799
|
-
<div className="fixed bottom-
|
|
800
|
-
<span className="text-
|
|
801
|
-
|
|
802
|
-
</span>
|
|
803
|
-
<Button variant="ghost" className="text-base" onClick={() => setRowSelection({})}>
|
|
728
|
+
<div className="fixed bottom-6 left-1/2 z-30 flex w-[min(560px,calc(100vw-2rem))] -translate-x-1/2 items-center gap-2 rounded-xl border bg-popover p-2 pl-4 text-popover-foreground shadow-lg animate-in fade-in-0 slide-in-from-bottom-2">
|
|
729
|
+
<span className="text-sm font-medium tabular-nums">{selectedIds.length} selected</span>
|
|
730
|
+
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setRowSelection({})}>
|
|
804
731
|
Clear
|
|
805
732
|
</Button>
|
|
806
733
|
<div className="ml-auto flex gap-2">
|
|
807
734
|
<Popover open={addToQuizOpen} onOpenChange={openAddToQuiz}>
|
|
808
|
-
<PopoverTrigger
|
|
809
|
-
|
|
810
|
-
Add to quiz
|
|
811
|
-
</Button>
|
|
812
|
-
</PopoverTrigger>
|
|
813
|
-
<PopoverContent side="top" align="end" className="w-80 p-1">
|
|
735
|
+
<PopoverTrigger render={<Button variant="outline" />}>Add to quiz</PopoverTrigger>
|
|
736
|
+
<PopoverContent side="top" align="end" sideOffset={8} className="w-80 gap-0 p-1">
|
|
814
737
|
{existingQuizzes === null ? (
|
|
815
|
-
<p className="
|
|
738
|
+
<p className="px-3 py-4 text-center text-sm text-muted-foreground">Loading…</p>
|
|
816
739
|
) : existingQuizzes.length === 0 ? (
|
|
817
|
-
<p className="
|
|
740
|
+
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No quizzes yet</p>
|
|
818
741
|
) : (
|
|
819
|
-
<ul className="
|
|
742
|
+
<ul className="max-h-72 overflow-y-auto">
|
|
820
743
|
{existingQuizzes.map((quiz) => {
|
|
821
744
|
const newCount = selectedIds.filter((id) => !quiz.questionIds.includes(id)).length;
|
|
822
745
|
return (
|
|
@@ -825,14 +748,14 @@ export function QuestionBank() {
|
|
|
825
748
|
type="button"
|
|
826
749
|
disabled={addingToQuizId !== null}
|
|
827
750
|
onClick={() => addToQuiz(quiz)}
|
|
828
|
-
className="flex w-full flex-col items-start rounded-
|
|
751
|
+
className="flex w-full flex-col items-start gap-0.5 rounded-md px-2.5 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none disabled:opacity-50"
|
|
829
752
|
>
|
|
830
753
|
<span className="text-sm font-medium">{quiz.title}</span>
|
|
831
|
-
<span className="text-xs text-muted-foreground">
|
|
754
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
832
755
|
{addingToQuizId === quiz.id
|
|
833
|
-
? 'Adding
|
|
834
|
-
: `${quiz.questionIds.length} ${quiz.questionIds.length === 1 ? 'question' : 'questions'}
|
|
835
|
-
newCount === 0 ? 'all
|
|
756
|
+
? 'Adding…'
|
|
757
|
+
: `${quiz.questionIds.length} ${quiz.questionIds.length === 1 ? 'question' : 'questions'} · ${
|
|
758
|
+
newCount === 0 ? 'all already included' : `+${newCount} new`
|
|
836
759
|
}`}
|
|
837
760
|
</span>
|
|
838
761
|
</button>
|
|
@@ -842,14 +765,14 @@ export function QuestionBank() {
|
|
|
842
765
|
</ul>
|
|
843
766
|
)}
|
|
844
767
|
{addToQuizError && (
|
|
845
|
-
<p role="alert" className="
|
|
846
|
-
Could not add to that quiz.
|
|
768
|
+
<p role="alert" className="px-2.5 py-2 text-sm text-destructive">
|
|
769
|
+
Could not add to that quiz.
|
|
847
770
|
</p>
|
|
848
771
|
)}
|
|
849
772
|
</PopoverContent>
|
|
850
773
|
</Popover>
|
|
851
|
-
<Button
|
|
852
|
-
{creatingQuiz ? 'Making quiz
|
|
774
|
+
<Button onClick={makeQuiz} disabled={creatingQuiz}>
|
|
775
|
+
{creatingQuiz ? 'Making quiz…' : 'Make quiz'}
|
|
853
776
|
</Button>
|
|
854
777
|
</div>
|
|
855
778
|
</div>
|