question-bank-framework 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -2
- package/bin/qb.js +119 -0
- package/bin/qb.ts +128 -0
- package/core/config.tsx +52 -0
- package/core/index.ts +35 -0
- package/core/types.ts +256 -0
- package/framework/components/app-shell.tsx +54 -0
- package/framework/components/filter-builder.tsx +471 -0
- package/framework/components/markdown-content.tsx +109 -0
- package/framework/components/question-bank.tsx +690 -0
- package/framework/components/quiz-list.tsx +118 -0
- package/framework/components/quiz-page.tsx +454 -0
- package/framework/components/ui/badge.tsx +47 -0
- package/framework/components/ui/button.tsx +63 -0
- package/framework/components/ui/checkbox.tsx +29 -0
- package/framework/components/ui/dropdown-menu.tsx +256 -0
- package/framework/components/ui/input.tsx +20 -0
- package/framework/components/ui/popover.tsx +88 -0
- package/framework/components/ui/select.tsx +187 -0
- package/framework/components/ui/sheet.tsx +140 -0
- package/framework/components/ui/table.tsx +121 -0
- package/framework/convex/questionStatuses.ts +30 -0
- package/framework/convex/quizzes.ts +50 -0
- package/framework/convex/schema.ts +18 -0
- package/framework/index.ts +26 -0
- package/framework/lib/convex-api.ts +36 -0
- package/framework/lib/question-filter-store.ts +116 -0
- package/framework/lib/question-filter.ts +376 -0
- package/framework/lib/question-status-storage.ts +117 -0
- package/framework/lib/quiz-storage.ts +118 -0
- package/framework/lib/utils.ts +6 -0
- package/package.json +66 -4
- package/template/bank.config.ts +72 -0
- package/template/content/answers.ts +8 -0
- package/template/content/questions.json +76 -0
- package/template/src/routes/__root.tsx +44 -0
- package/template/src/routes/index.tsx +4 -0
- package/template/src/routes/quiz.$quizId.tsx +4 -0
- package/template/src/routes/quizzes.tsx +4 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// Framework-owned: eject with `qb reveal framework/components/question-bank.tsx`.
|
|
2
|
+
// Generic grid: columns + filters derive from bank.fields (config-defined
|
|
3
|
+
// schema). Built-ins: select, id, title, tags, status.
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { useNavigate } from '@tanstack/react-router';
|
|
7
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
8
|
+
import {
|
|
9
|
+
flexRender,
|
|
10
|
+
getCoreRowModel,
|
|
11
|
+
getFilteredRowModel,
|
|
12
|
+
getSortedRowModel,
|
|
13
|
+
useReactTable,
|
|
14
|
+
type ColumnDef,
|
|
15
|
+
type RowSelectionState,
|
|
16
|
+
type SortingState,
|
|
17
|
+
} from '@tanstack/react-table';
|
|
18
|
+
import { Search } from 'lucide-react';
|
|
19
|
+
import { useBank } from '../../core/config.tsx';
|
|
20
|
+
import type { BaseQuestion, QuestionKind, QuestionStatus, QuestionStatusMap } from '../../core/types.ts';
|
|
21
|
+
import { isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
|
|
22
|
+
import { Badge } from './ui/badge';
|
|
23
|
+
import { Button } from './ui/button';
|
|
24
|
+
import { Checkbox } from './ui/checkbox';
|
|
25
|
+
import { Input } from './ui/input';
|
|
26
|
+
import { FilterBuilder } from './filter-builder';
|
|
27
|
+
import { MarkdownContent } from './markdown-content';
|
|
28
|
+
import { Popover, PopoverAnchor, PopoverContent } from './ui/popover';
|
|
29
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
|
30
|
+
import {
|
|
31
|
+
createFilterFields,
|
|
32
|
+
defaultOperatorByType,
|
|
33
|
+
matchesFilters,
|
|
34
|
+
matchesSearch,
|
|
35
|
+
questionStatusLabels,
|
|
36
|
+
} from '../lib/question-filter.ts';
|
|
37
|
+
import { useQuestionFilterStore } from '../lib/question-filter-store.ts';
|
|
38
|
+
import { cn } from '../lib/utils.ts';
|
|
39
|
+
import { getQuestionStatuses } from '../lib/question-status-storage.ts';
|
|
40
|
+
import { createQuiz } from '../lib/quiz-storage.ts';
|
|
41
|
+
|
|
42
|
+
type DragSelection = {
|
|
43
|
+
value: boolean;
|
|
44
|
+
startIndex: number;
|
|
45
|
+
currentIndex: number;
|
|
46
|
+
originalSelection: RowSelectionState;
|
|
47
|
+
pointerId: number;
|
|
48
|
+
startX: number;
|
|
49
|
+
startY: number;
|
|
50
|
+
clientX: number;
|
|
51
|
+
clientY: number;
|
|
52
|
+
moved: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const previewCloseDelay = 180;
|
|
56
|
+
const previewOpenDelay = 400;
|
|
57
|
+
const questionStatusStyles: Record<QuestionStatus, string> = {
|
|
58
|
+
unanswered: 'border-gray-300 bg-gray-50 text-gray-700',
|
|
59
|
+
revisit: 'border-amber-300 bg-amber-50 text-amber-900',
|
|
60
|
+
complete: 'border-green-300 bg-green-50 text-green-800',
|
|
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
|
+
|
|
71
|
+
function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
|
|
72
|
+
const [open, setOpen] = useState(false);
|
|
73
|
+
const openTimeout = useRef<number | null>(null);
|
|
74
|
+
const closeTimeout = useRef<number | null>(null);
|
|
75
|
+
const openedByPointer = useRef(false);
|
|
76
|
+
|
|
77
|
+
const clearOpenTimeout = () => {
|
|
78
|
+
if (openTimeout.current !== null) {
|
|
79
|
+
window.clearTimeout(openTimeout.current);
|
|
80
|
+
openTimeout.current = null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const clearCloseTimeout = () => {
|
|
84
|
+
if (closeTimeout.current !== null) {
|
|
85
|
+
window.clearTimeout(closeTimeout.current);
|
|
86
|
+
closeTimeout.current = null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const openPreview = () => {
|
|
90
|
+
clearOpenTimeout();
|
|
91
|
+
clearCloseTimeout();
|
|
92
|
+
setOpen(true);
|
|
93
|
+
};
|
|
94
|
+
const scheduleOpen = () => {
|
|
95
|
+
clearOpenTimeout();
|
|
96
|
+
clearCloseTimeout();
|
|
97
|
+
openTimeout.current = window.setTimeout(() => {
|
|
98
|
+
setOpen(true);
|
|
99
|
+
openTimeout.current = null;
|
|
100
|
+
}, previewOpenDelay);
|
|
101
|
+
};
|
|
102
|
+
const scheduleClose = () => {
|
|
103
|
+
clearOpenTimeout();
|
|
104
|
+
clearCloseTimeout();
|
|
105
|
+
closeTimeout.current = window.setTimeout(() => {
|
|
106
|
+
setOpen(false);
|
|
107
|
+
openedByPointer.current = false;
|
|
108
|
+
closeTimeout.current = null;
|
|
109
|
+
}, previewCloseDelay);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
useEffect(
|
|
113
|
+
() => () => {
|
|
114
|
+
if (openTimeout.current !== null) window.clearTimeout(openTimeout.current);
|
|
115
|
+
if (closeTimeout.current !== null) window.clearTimeout(closeTimeout.current);
|
|
116
|
+
},
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Popover
|
|
122
|
+
open={open}
|
|
123
|
+
onOpenChange={(nextOpen) => {
|
|
124
|
+
if (nextOpen) {
|
|
125
|
+
openPreview();
|
|
126
|
+
} else {
|
|
127
|
+
clearOpenTimeout();
|
|
128
|
+
clearCloseTimeout();
|
|
129
|
+
setOpen(false);
|
|
130
|
+
openedByPointer.current = false;
|
|
131
|
+
}
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<PopoverAnchor asChild>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
aria-haspopup="dialog"
|
|
138
|
+
aria-expanded={open}
|
|
139
|
+
aria-label={`Preview question: ${question.title}`}
|
|
140
|
+
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"
|
|
141
|
+
onPointerEnter={() => {
|
|
142
|
+
openedByPointer.current = true;
|
|
143
|
+
scheduleOpen();
|
|
144
|
+
}}
|
|
145
|
+
onPointerLeave={scheduleClose}
|
|
146
|
+
onFocus={() => {
|
|
147
|
+
if (!openedByPointer.current) scheduleOpen();
|
|
148
|
+
}}
|
|
149
|
+
onBlur={scheduleClose}
|
|
150
|
+
onClick={(event) => {
|
|
151
|
+
event.stopPropagation();
|
|
152
|
+
scheduleOpen();
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{question.title}
|
|
156
|
+
</button>
|
|
157
|
+
</PopoverAnchor>
|
|
158
|
+
<PopoverContent
|
|
159
|
+
side="right"
|
|
160
|
+
align="start"
|
|
161
|
+
sideOffset={8}
|
|
162
|
+
collisionPadding={12}
|
|
163
|
+
aria-label={`Preview: ${question.title}`}
|
|
164
|
+
className="max-h-[min(70dvh,calc(100dvh-1.5rem))] w-[min(680px,calc(100vw-2rem))] max-w-[calc(100vw-1.5rem)] overflow-y-auto p-0"
|
|
165
|
+
onOpenAutoFocus={(event) => {
|
|
166
|
+
if (openedByPointer.current) event.preventDefault();
|
|
167
|
+
}}
|
|
168
|
+
onPointerEnter={openPreview}
|
|
169
|
+
onPointerLeave={scheduleClose}
|
|
170
|
+
onFocusCapture={openPreview}
|
|
171
|
+
onBlurCapture={scheduleClose}
|
|
172
|
+
>
|
|
173
|
+
<div className="px-4 py-3">
|
|
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
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</PopoverContent>
|
|
189
|
+
</Popover>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatCellValue(value: unknown): string {
|
|
194
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
195
|
+
if (Array.isArray(value)) return value.join(', ');
|
|
196
|
+
return String(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function QuestionBank() {
|
|
200
|
+
const bank = useBank();
|
|
201
|
+
const navigate = useNavigate();
|
|
202
|
+
const search = useQuestionFilterStore((state) => state.search);
|
|
203
|
+
const filters = useQuestionFilterStore((state) => state.filters);
|
|
204
|
+
const filterMatch = useQuestionFilterStore((state) => state.filterMatch);
|
|
205
|
+
const setSearch = useQuestionFilterStore((state) => state.setSearch);
|
|
206
|
+
const addFilter = useQuestionFilterStore((state) => state.addFilter);
|
|
207
|
+
const updateFilter = useQuestionFilterStore((state) => state.updateFilter);
|
|
208
|
+
const removeFilter = useQuestionFilterStore((state) => state.removeFilter);
|
|
209
|
+
const clearFilters = useQuestionFilterStore((state) => state.clearFilters);
|
|
210
|
+
const setFilterMatch = useQuestionFilterStore((state) => state.setFilterMatch);
|
|
211
|
+
const [questionStatusMap, setQuestionStatusMap] = useState<QuestionStatusMap>({});
|
|
212
|
+
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
|
213
|
+
const [sorting, setSorting] = useState<SortingState>(
|
|
214
|
+
(bank.defaultSorting ?? [{ id: 'id' }]).map((s) => ({ id: s.id, desc: s.desc ?? false })),
|
|
215
|
+
);
|
|
216
|
+
const [creatingQuiz, setCreatingQuiz] = useState(false);
|
|
217
|
+
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
218
|
+
const rowSelectionRef = useRef(rowSelection);
|
|
219
|
+
rowSelectionRef.current = rowSelection;
|
|
220
|
+
const dragSelection = useRef<DragSelection | null>(null);
|
|
221
|
+
const pendingCheckboxChange = useRef<{ rowId: string; value: boolean } | null>(null);
|
|
222
|
+
const suppressRowClick = useRef<string | null>(null);
|
|
223
|
+
|
|
224
|
+
const filterFields = useMemo(() => createFilterFields(bank.questions, bank.fields), [bank]);
|
|
225
|
+
|
|
226
|
+
const handleAddFilter = (field: string) => {
|
|
227
|
+
const type = filterFields.find((f) => f.id === field)?.type ?? 'text';
|
|
228
|
+
addFilter(field, defaultOperatorByType[type]);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
void useQuestionFilterStore.persist.rehydrate();
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
let active = true;
|
|
237
|
+
getQuestionStatuses(bank.namespace).then((statuses) => {
|
|
238
|
+
if (active) setQuestionStatusMap(statuses);
|
|
239
|
+
});
|
|
240
|
+
return () => {
|
|
241
|
+
active = false;
|
|
242
|
+
};
|
|
243
|
+
}, [bank.namespace]);
|
|
244
|
+
|
|
245
|
+
const filteredQuestions = useMemo(
|
|
246
|
+
() =>
|
|
247
|
+
bank.questions.filter(
|
|
248
|
+
(question) =>
|
|
249
|
+
matchesFilters(filters, filterMatch, filterFields, bank.fields, question, questionStatusMap[question.id] ?? 'unanswered') &&
|
|
250
|
+
matchesSearch(question, search, bank.fields),
|
|
251
|
+
),
|
|
252
|
+
[bank, filters, filterMatch, filterFields, questionStatusMap, search],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const columns = useMemo<ColumnDef<BaseQuestion>[]>(() => {
|
|
256
|
+
const customs: ColumnDef<BaseQuestion>[] = bank.fields
|
|
257
|
+
.filter((def) => def.column !== false)
|
|
258
|
+
.map((def) => {
|
|
259
|
+
const header = def.column && typeof def.column === 'object' && def.column.header ? def.column.header : def.label;
|
|
260
|
+
const width = def.column && typeof def.column === 'object' && def.column.width ? def.column.width : undefined;
|
|
261
|
+
const sortable = def.column && typeof def.column === 'object' && def.column.sortable === false ? false : true;
|
|
262
|
+
return {
|
|
263
|
+
id: def.id,
|
|
264
|
+
accessorFn: (row) => {
|
|
265
|
+
const raw = def.getValue(row);
|
|
266
|
+
return Array.isArray(raw) ? raw.join(', ') : (raw ?? '');
|
|
267
|
+
},
|
|
268
|
+
header,
|
|
269
|
+
cell: ({ row }) => {
|
|
270
|
+
const raw = def.getValue(row.original);
|
|
271
|
+
if (def.type === 'multiselect' && Array.isArray(raw)) {
|
|
272
|
+
return (
|
|
273
|
+
<div className="flex flex-wrap gap-1">
|
|
274
|
+
{(raw as readonly string[]).map((tag) => (
|
|
275
|
+
<Badge key={String(tag)} variant="secondary">
|
|
276
|
+
{String(tag)}
|
|
277
|
+
</Badge>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return <span className="text-sm text-foreground">{formatCellValue(raw)}</span>;
|
|
283
|
+
},
|
|
284
|
+
enableSorting: sortable,
|
|
285
|
+
size: width,
|
|
286
|
+
} satisfies ColumnDef<BaseQuestion>;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return [
|
|
290
|
+
{
|
|
291
|
+
id: 'select',
|
|
292
|
+
header: ({ table }) => (
|
|
293
|
+
<Checkbox
|
|
294
|
+
aria-label="Select all matching questions"
|
|
295
|
+
checked={table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? 'indeterminate' : false}
|
|
296
|
+
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}
|
|
297
|
+
/>
|
|
298
|
+
),
|
|
299
|
+
cell: ({ row }) => (
|
|
300
|
+
<Checkbox
|
|
301
|
+
aria-label={`Select ${row.original.title}`}
|
|
302
|
+
checked={row.getIsSelected()}
|
|
303
|
+
onPointerDown={(event) => {
|
|
304
|
+
if (event.button !== 0) return;
|
|
305
|
+
const startIndex = Number(event.currentTarget.closest<HTMLElement>('[data-virtual-index]')?.dataset.virtualIndex);
|
|
306
|
+
if (!Number.isInteger(startIndex)) return;
|
|
307
|
+
suppressRowClick.current = null;
|
|
308
|
+
const value = !row.getIsSelected();
|
|
309
|
+
pendingCheckboxChange.current = { rowId: row.id, value };
|
|
310
|
+
dragSelection.current = {
|
|
311
|
+
value,
|
|
312
|
+
startIndex,
|
|
313
|
+
currentIndex: startIndex,
|
|
314
|
+
originalSelection: { ...rowSelectionRef.current },
|
|
315
|
+
pointerId: event.pointerId,
|
|
316
|
+
startX: event.clientX,
|
|
317
|
+
startY: event.clientY,
|
|
318
|
+
clientX: event.clientX,
|
|
319
|
+
clientY: event.clientY,
|
|
320
|
+
moved: false,
|
|
321
|
+
};
|
|
322
|
+
setRowSelection((current) => (current[row.id] === value ? current : { ...current, [row.id]: value }));
|
|
323
|
+
}}
|
|
324
|
+
onCheckedChange={(checked) => {
|
|
325
|
+
const pendingChange = pendingCheckboxChange.current;
|
|
326
|
+
if (pendingChange?.rowId === row.id) pendingCheckboxChange.current = null;
|
|
327
|
+
const value = pendingChange?.rowId === row.id ? pendingChange.value : checked === true;
|
|
328
|
+
setRowSelection((current) => (current[row.id] === value ? current : { ...current, [row.id]: value }));
|
|
329
|
+
}}
|
|
330
|
+
/>
|
|
331
|
+
),
|
|
332
|
+
enableSorting: false,
|
|
333
|
+
size: 44,
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
accessorKey: 'id',
|
|
337
|
+
header: 'ID',
|
|
338
|
+
cell: ({ row }) => <span className="font-mono text-sm text-muted-foreground">{row.original.id}</span>,
|
|
339
|
+
size: 104,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
accessorKey: 'title',
|
|
343
|
+
header: 'Question',
|
|
344
|
+
cell: ({ row }) => <QuestionPreviewPopover question={row.original} />,
|
|
345
|
+
size: 340,
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: 'tags',
|
|
349
|
+
accessorFn: (row) => row.tags.join(', '),
|
|
350
|
+
header: 'Tags',
|
|
351
|
+
cell: ({ row }) => (
|
|
352
|
+
<div className="flex flex-wrap gap-1">
|
|
353
|
+
{row.original.tags.map((tag) => (
|
|
354
|
+
<Badge key={tag} variant="secondary">
|
|
355
|
+
{tag}
|
|
356
|
+
</Badge>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: 'kind',
|
|
363
|
+
accessorFn: (row) => questionKind(row),
|
|
364
|
+
header: 'Type',
|
|
365
|
+
cell: ({ row }) => {
|
|
366
|
+
const kind = questionKind(row.original);
|
|
367
|
+
const count = row.original.options?.length ?? 0;
|
|
368
|
+
return (
|
|
369
|
+
<Badge variant="outline" className={questionKindStyles[kind]} title={count > 0 ? `${count} options` : undefined}>
|
|
370
|
+
{questionKindLabels[kind]}
|
|
371
|
+
</Badge>
|
|
372
|
+
);
|
|
373
|
+
},
|
|
374
|
+
size: 132,
|
|
375
|
+
},
|
|
376
|
+
...customs,
|
|
377
|
+
{
|
|
378
|
+
id: 'status',
|
|
379
|
+
accessorFn: (row) => questionStatusMap[row.id] ?? 'unanswered',
|
|
380
|
+
header: 'Status',
|
|
381
|
+
cell: ({ row }) => {
|
|
382
|
+
const status = questionStatusMap[row.original.id] ?? 'unanswered';
|
|
383
|
+
return (
|
|
384
|
+
<Badge variant="outline" className={questionStatusStyles[status]}>
|
|
385
|
+
{questionStatusLabels[status]}
|
|
386
|
+
</Badge>
|
|
387
|
+
);
|
|
388
|
+
},
|
|
389
|
+
size: 108,
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
}, [bank.fields, questionStatusMap]);
|
|
393
|
+
|
|
394
|
+
const table = useReactTable({
|
|
395
|
+
data: filteredQuestions,
|
|
396
|
+
columns,
|
|
397
|
+
state: { globalFilter: search, rowSelection, sorting },
|
|
398
|
+
onRowSelectionChange: setRowSelection,
|
|
399
|
+
onSortingChange: setSorting,
|
|
400
|
+
globalFilterFn: (row, _columnId, filterValue) => matchesSearch(row.original, String(filterValue ?? ''), bank.fields),
|
|
401
|
+
getRowId: (row) => row.id,
|
|
402
|
+
getCoreRowModel: getCoreRowModel(),
|
|
403
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
404
|
+
getSortedRowModel: getSortedRowModel(),
|
|
405
|
+
});
|
|
406
|
+
const tableRows = table.getRowModel().rows;
|
|
407
|
+
const rowVirtualizer = useVirtualizer({
|
|
408
|
+
count: tableRows.length,
|
|
409
|
+
getScrollElement: () => tableContainerRef.current,
|
|
410
|
+
estimateSize: () => 56,
|
|
411
|
+
getItemKey: (index) => tableRows[index]?.id ?? index,
|
|
412
|
+
overscan: 8,
|
|
413
|
+
});
|
|
414
|
+
const virtualRows = rowVirtualizer.getVirtualItems();
|
|
415
|
+
const topPadding = virtualRows[0]?.start ?? 0;
|
|
416
|
+
const bottomPadding =
|
|
417
|
+
virtualRows.length > 0 ? Math.max(rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1]!.end, 0) : 0;
|
|
418
|
+
|
|
419
|
+
const updateDragRange = useCallback(
|
|
420
|
+
(index: number) => {
|
|
421
|
+
const activeDrag = dragSelection.current;
|
|
422
|
+
if (!activeDrag || index < 0 || index >= tableRows.length || activeDrag.currentIndex === index) return;
|
|
423
|
+
const previousStart = Math.min(activeDrag.startIndex, activeDrag.currentIndex);
|
|
424
|
+
const previousEnd = Math.max(activeDrag.startIndex, activeDrag.currentIndex);
|
|
425
|
+
const nextStart = Math.min(activeDrag.startIndex, index);
|
|
426
|
+
const nextEnd = Math.max(activeDrag.startIndex, index);
|
|
427
|
+
const affectedStart = Math.min(previousStart, nextStart);
|
|
428
|
+
const affectedEnd = Math.max(previousEnd, nextEnd);
|
|
429
|
+
activeDrag.currentIndex = index;
|
|
430
|
+
activeDrag.moved = true;
|
|
431
|
+
setRowSelection((current) => {
|
|
432
|
+
const next = { ...current };
|
|
433
|
+
let changed = false;
|
|
434
|
+
for (let rowIndex = affectedStart; rowIndex <= affectedEnd; rowIndex += 1) {
|
|
435
|
+
const row = tableRows[rowIndex];
|
|
436
|
+
if (!row) continue;
|
|
437
|
+
const selected = rowIndex >= nextStart && rowIndex <= nextEnd ? activeDrag.value : activeDrag.originalSelection[row.id] === true;
|
|
438
|
+
if ((current[row.id] === true) === selected) continue;
|
|
439
|
+
if (selected) next[row.id] = true;
|
|
440
|
+
else delete next[row.id];
|
|
441
|
+
changed = true;
|
|
442
|
+
}
|
|
443
|
+
return changed ? next : current;
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
[tableRows],
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
let animationFrame = 0;
|
|
451
|
+
const autoScroll = () => {
|
|
452
|
+
animationFrame = 0;
|
|
453
|
+
const activeDrag = dragSelection.current;
|
|
454
|
+
const container = tableContainerRef.current;
|
|
455
|
+
if (!activeDrag?.moved || !container) return;
|
|
456
|
+
const rect = container.getBoundingClientRect();
|
|
457
|
+
const viewportTop = rect.top + container.clientTop;
|
|
458
|
+
const viewportBottom = viewportTop + container.clientHeight;
|
|
459
|
+
const edgeSize = Math.min(56, container.clientHeight / 3);
|
|
460
|
+
let scrollDelta = 0;
|
|
461
|
+
if (activeDrag.clientY < viewportTop + edgeSize) {
|
|
462
|
+
const distanceIntoEdge = Math.min(1, (viewportTop + edgeSize - activeDrag.clientY) / edgeSize);
|
|
463
|
+
scrollDelta = -Math.ceil(2 + 14 * distanceIntoEdge ** 2);
|
|
464
|
+
} else if (activeDrag.clientY > viewportBottom - edgeSize) {
|
|
465
|
+
const distanceIntoEdge = Math.min(1, (activeDrag.clientY - (viewportBottom - edgeSize)) / edgeSize);
|
|
466
|
+
scrollDelta = Math.ceil(2 + 14 * distanceIntoEdge ** 2);
|
|
467
|
+
}
|
|
468
|
+
if (scrollDelta !== 0) {
|
|
469
|
+
const previousScrollTop = container.scrollTop;
|
|
470
|
+
container.scrollTop += scrollDelta;
|
|
471
|
+
if (container.scrollTop !== previousScrollTop && animationFrame === 0) {
|
|
472
|
+
animationFrame = window.requestAnimationFrame(autoScroll);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
let targetRow = document.elementFromPoint(activeDrag.clientX, activeDrag.clientY)?.closest<HTMLElement>('[data-virtual-index]');
|
|
476
|
+
if (!targetRow) {
|
|
477
|
+
const visibleRows = [...container.querySelectorAll<HTMLElement>('[data-virtual-index]')].filter((row) => {
|
|
478
|
+
const rowRect = row.getBoundingClientRect();
|
|
479
|
+
return rowRect.bottom > viewportTop && rowRect.top < viewportBottom;
|
|
480
|
+
});
|
|
481
|
+
if (activeDrag.clientY < viewportTop + edgeSize) targetRow = visibleRows[0];
|
|
482
|
+
else if (activeDrag.clientY > viewportBottom - edgeSize) targetRow = visibleRows[visibleRows.length - 1];
|
|
483
|
+
}
|
|
484
|
+
const targetIndex = Number(targetRow?.dataset.virtualIndex);
|
|
485
|
+
if (Number.isInteger(targetIndex)) updateDragRange(targetIndex);
|
|
486
|
+
};
|
|
487
|
+
const trackPointer = (event: PointerEvent) => {
|
|
488
|
+
const activeDrag = dragSelection.current;
|
|
489
|
+
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return;
|
|
490
|
+
activeDrag.clientX = event.clientX;
|
|
491
|
+
activeDrag.clientY = event.clientY;
|
|
492
|
+
if (!activeDrag.moved && Math.hypot(event.clientX - activeDrag.startX, event.clientY - activeDrag.startY) >= 5) {
|
|
493
|
+
activeDrag.moved = true;
|
|
494
|
+
}
|
|
495
|
+
if (activeDrag.moved && animationFrame === 0) {
|
|
496
|
+
animationFrame = window.requestAnimationFrame(autoScroll);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const stopDragSelection = (event: PointerEvent) => {
|
|
500
|
+
const activeDrag = dragSelection.current;
|
|
501
|
+
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return;
|
|
502
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
503
|
+
const targetCheckbox = target?.closest<HTMLElement>('[role="checkbox"]');
|
|
504
|
+
const targetRow = target?.closest<HTMLElement>('[data-question-row]');
|
|
505
|
+
if (event.type === 'pointerup' && targetRow && !targetCheckbox) {
|
|
506
|
+
suppressRowClick.current = targetRow.dataset.questionRow ?? null;
|
|
507
|
+
}
|
|
508
|
+
const pendingChange = pendingCheckboxChange.current;
|
|
509
|
+
const checkboxRow = targetCheckbox?.closest<HTMLElement>('[data-question-row]');
|
|
510
|
+
if (event.type !== 'pointerup' || checkboxRow?.dataset.questionRow !== pendingChange?.rowId) {
|
|
511
|
+
pendingCheckboxChange.current = null;
|
|
512
|
+
} else if (pendingChange) {
|
|
513
|
+
window.setTimeout(() => {
|
|
514
|
+
if (pendingCheckboxChange.current === pendingChange) pendingCheckboxChange.current = null;
|
|
515
|
+
}, 0);
|
|
516
|
+
}
|
|
517
|
+
dragSelection.current = null;
|
|
518
|
+
if (animationFrame !== 0) window.cancelAnimationFrame(animationFrame);
|
|
519
|
+
animationFrame = 0;
|
|
520
|
+
};
|
|
521
|
+
window.addEventListener('pointermove', trackPointer);
|
|
522
|
+
window.addEventListener('pointerup', stopDragSelection);
|
|
523
|
+
window.addEventListener('pointercancel', stopDragSelection);
|
|
524
|
+
return () => {
|
|
525
|
+
window.removeEventListener('pointermove', trackPointer);
|
|
526
|
+
window.removeEventListener('pointerup', stopDragSelection);
|
|
527
|
+
window.removeEventListener('pointercancel', stopDragSelection);
|
|
528
|
+
if (animationFrame !== 0) window.cancelAnimationFrame(animationFrame);
|
|
529
|
+
};
|
|
530
|
+
}, [updateDragRange]);
|
|
531
|
+
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
tableContainerRef.current?.scrollTo({ top: 0 });
|
|
534
|
+
}, [filteredQuestions, search, sorting]);
|
|
535
|
+
|
|
536
|
+
const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]);
|
|
537
|
+
|
|
538
|
+
const makeQuiz = async () => {
|
|
539
|
+
const chosenQuestions = bank.questions.filter((question) => rowSelection[question.id]);
|
|
540
|
+
if (chosenQuestions.length === 0) return;
|
|
541
|
+
setCreatingQuiz(true);
|
|
542
|
+
const title = `${chosenQuestions.length}-question practice set`;
|
|
543
|
+
try {
|
|
544
|
+
const quiz = await createQuiz(bank.namespace, title, chosenQuestions.map((question) => question.id));
|
|
545
|
+
await navigate({ to: '/quiz/$quizId', params: { quizId: quiz.id } });
|
|
546
|
+
} finally {
|
|
547
|
+
setCreatingQuiz(false);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
553
|
+
<div className="border-b bg-background px-4 py-3">
|
|
554
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
555
|
+
<label className="relative min-w-[240px] flex-1">
|
|
556
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
557
|
+
<Input
|
|
558
|
+
value={search}
|
|
559
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
560
|
+
placeholder="Search questions, topics, exams..."
|
|
561
|
+
aria-label="Search questions"
|
|
562
|
+
className="h-11 pl-10 text-base"
|
|
563
|
+
/>
|
|
564
|
+
</label>
|
|
565
|
+
<FilterBuilder
|
|
566
|
+
fields={filterFields}
|
|
567
|
+
filters={filters}
|
|
568
|
+
match={filterMatch}
|
|
569
|
+
onAdd={handleAddFilter}
|
|
570
|
+
onChange={updateFilter}
|
|
571
|
+
onRemove={removeFilter}
|
|
572
|
+
onClear={clearFilters}
|
|
573
|
+
onMatchChange={setFilterMatch}
|
|
574
|
+
/>
|
|
575
|
+
{filters.length > 0 && (
|
|
576
|
+
<Button variant="ghost" className="h-11 text-base" onClick={clearFilters}>
|
|
577
|
+
Clear filters
|
|
578
|
+
</Button>
|
|
579
|
+
)}
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
<Table containerRef={tableContainerRef} containerClassName="min-h-0 flex-1 overflow-auto" className="min-w-[1200px] table-fixed">
|
|
584
|
+
<TableHeader>
|
|
585
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
586
|
+
<TableRow key={headerGroup.id}>
|
|
587
|
+
{headerGroup.headers.map((header) => (
|
|
588
|
+
<TableHead
|
|
589
|
+
key={header.id}
|
|
590
|
+
style={header.column.id === 'tags' ? undefined : { width: header.getSize() }}
|
|
591
|
+
className="sticky top-0 z-10 h-12 border-b bg-muted px-4 text-sm font-semibold text-foreground"
|
|
592
|
+
>
|
|
593
|
+
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
594
|
+
<button className="font-semibold hover:underline" onClick={header.column.getToggleSortingHandler()}>
|
|
595
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
596
|
+
</button>
|
|
597
|
+
) : (
|
|
598
|
+
flexRender(header.column.columnDef.header, header.getContext())
|
|
599
|
+
)}
|
|
600
|
+
</TableHead>
|
|
601
|
+
))}
|
|
602
|
+
</TableRow>
|
|
603
|
+
))}
|
|
604
|
+
</TableHeader>
|
|
605
|
+
<TableBody>
|
|
606
|
+
{tableRows.length > 0 ? (
|
|
607
|
+
<>
|
|
608
|
+
{topPadding > 0 && (
|
|
609
|
+
<TableRow aria-hidden="true" className="border-0">
|
|
610
|
+
<TableCell colSpan={columns.length} className="border-0 p-0" style={{ height: topPadding }} />
|
|
611
|
+
</TableRow>
|
|
612
|
+
)}
|
|
613
|
+
{virtualRows.map((virtualRow) => {
|
|
614
|
+
const row = tableRows[virtualRow.index];
|
|
615
|
+
if (!row) return null;
|
|
616
|
+
return (
|
|
617
|
+
<TableRow
|
|
618
|
+
key={row.id}
|
|
619
|
+
ref={rowVirtualizer.measureElement}
|
|
620
|
+
data-index={virtualRow.index}
|
|
621
|
+
data-virtual-index={virtualRow.index}
|
|
622
|
+
data-question-row={row.id}
|
|
623
|
+
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
624
|
+
className="group cursor-pointer"
|
|
625
|
+
onPointerEnter={() => updateDragRange(virtualRow.index)}
|
|
626
|
+
onClick={() => {
|
|
627
|
+
if (suppressRowClick.current === row.id) {
|
|
628
|
+
suppressRowClick.current = null;
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (!dragSelection.current) row.toggleSelected();
|
|
632
|
+
}}
|
|
633
|
+
>
|
|
634
|
+
{row.getVisibleCells().map((cell) => (
|
|
635
|
+
<TableCell
|
|
636
|
+
key={cell.id}
|
|
637
|
+
className={cn('border-b px-4 py-2 align-middle', cell.column.id === 'title' && 'whitespace-normal')}
|
|
638
|
+
onClick={cell.column.id === 'select' ? (event) => event.stopPropagation() : undefined}
|
|
639
|
+
>
|
|
640
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
641
|
+
</TableCell>
|
|
642
|
+
))}
|
|
643
|
+
</TableRow>
|
|
644
|
+
);
|
|
645
|
+
})}
|
|
646
|
+
{bottomPadding > 0 && (
|
|
647
|
+
<TableRow aria-hidden="true" className="border-0">
|
|
648
|
+
<TableCell colSpan={columns.length} className="border-0 p-0" style={{ height: bottomPadding }} />
|
|
649
|
+
</TableRow>
|
|
650
|
+
)}
|
|
651
|
+
</>
|
|
652
|
+
) : (
|
|
653
|
+
<TableRow>
|
|
654
|
+
<TableCell colSpan={columns.length} className="h-40 text-center">
|
|
655
|
+
<p className="text-lg font-semibold">No matching questions</p>
|
|
656
|
+
<p className="mt-1 text-base text-muted-foreground">Try another search or clear your filters.</p>
|
|
657
|
+
<Button
|
|
658
|
+
variant="outline"
|
|
659
|
+
className="mt-4"
|
|
660
|
+
onClick={() => {
|
|
661
|
+
clearFilters();
|
|
662
|
+
setSearch('');
|
|
663
|
+
}}
|
|
664
|
+
>
|
|
665
|
+
Clear search and filters
|
|
666
|
+
</Button>
|
|
667
|
+
</TableCell>
|
|
668
|
+
</TableRow>
|
|
669
|
+
)}
|
|
670
|
+
</TableBody>
|
|
671
|
+
</Table>
|
|
672
|
+
|
|
673
|
+
{selectedIds.length > 0 && (
|
|
674
|
+
<div className="fixed bottom-5 right-1/2 z-30 flex w-[min(600px,calc(100vw-32px))] translate-x-1/2 items-center gap-3 rounded-xl border bg-background p-3 pl-4 shadow-lg">
|
|
675
|
+
<span className="text-base font-semibold">
|
|
676
|
+
{selectedIds.length} {selectedIds.length === 1 ? 'question' : 'questions'} selected
|
|
677
|
+
</span>
|
|
678
|
+
<Button variant="ghost" className="text-base" onClick={() => setRowSelection({})}>
|
|
679
|
+
Clear
|
|
680
|
+
</Button>
|
|
681
|
+
<div className="ml-auto">
|
|
682
|
+
<Button className="text-base" onClick={makeQuiz} disabled={creatingQuiz}>
|
|
683
|
+
{creatingQuiz ? 'Making quiz...' : 'Make quiz'}
|
|
684
|
+
</Button>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
);
|
|
690
|
+
}
|