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,471 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ChevronDown, Circle, Filter, Hash, Plus, Tags, Type, X } from 'lucide-react'
|
|
3
|
+
import { Badge } from './ui/badge'
|
|
4
|
+
import { Button } from './ui/button'
|
|
5
|
+
import { Checkbox } from './ui/checkbox'
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuRadioGroup,
|
|
10
|
+
DropdownMenuRadioItem,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from './ui/dropdown-menu'
|
|
13
|
+
import { Input } from './ui/input'
|
|
14
|
+
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
|
|
15
|
+
import {
|
|
16
|
+
Select,
|
|
17
|
+
SelectContent,
|
|
18
|
+
SelectItem,
|
|
19
|
+
SelectTrigger,
|
|
20
|
+
SelectValue,
|
|
21
|
+
} from './ui/select'
|
|
22
|
+
import {
|
|
23
|
+
defaultOperatorByType,
|
|
24
|
+
fieldTypeLabels,
|
|
25
|
+
operatorLabels,
|
|
26
|
+
type FilterField,
|
|
27
|
+
type FilterFieldType,
|
|
28
|
+
type FilterMatch,
|
|
29
|
+
type FilterOperatorId,
|
|
30
|
+
type FilterRule,
|
|
31
|
+
} from '../lib/question-filter.ts'
|
|
32
|
+
import { cn } from '../lib/utils.ts'
|
|
33
|
+
|
|
34
|
+
const fieldIcons: Record<FilterFieldType, typeof Filter> = {
|
|
35
|
+
text: Type,
|
|
36
|
+
number: Hash,
|
|
37
|
+
select: Circle,
|
|
38
|
+
multiselect: Tags,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type FilterBuilderProps = {
|
|
42
|
+
fields: readonly FilterField[]
|
|
43
|
+
filters: readonly FilterRule[]
|
|
44
|
+
match: FilterMatch
|
|
45
|
+
onAdd: (field: string) => void
|
|
46
|
+
onChange: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) => void
|
|
47
|
+
onRemove: (id: string) => void
|
|
48
|
+
onClear: () => void
|
|
49
|
+
onMatchChange: (match: FilterMatch) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function FilterBuilder({
|
|
53
|
+
fields,
|
|
54
|
+
filters,
|
|
55
|
+
match,
|
|
56
|
+
onAdd,
|
|
57
|
+
onChange,
|
|
58
|
+
onRemove,
|
|
59
|
+
onClear,
|
|
60
|
+
onMatchChange,
|
|
61
|
+
}: FilterBuilderProps) {
|
|
62
|
+
const [open, setOpen] = useState(false)
|
|
63
|
+
const [adding, setAdding] = useState(false)
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Popover
|
|
67
|
+
open={open}
|
|
68
|
+
onOpenChange={(nextOpen) => {
|
|
69
|
+
setOpen(nextOpen)
|
|
70
|
+
if (!nextOpen) setAdding(false)
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<PopoverTrigger asChild>
|
|
74
|
+
<Button variant="outline" className="h-11 gap-2 text-base">
|
|
75
|
+
<Filter className="size-4" />
|
|
76
|
+
Filter
|
|
77
|
+
{filters.length > 0 && (
|
|
78
|
+
<Badge variant="secondary" className="h-5 min-w-5 rounded-full px-1.5 text-xs tabular-nums">
|
|
79
|
+
{filters.length}
|
|
80
|
+
</Badge>
|
|
81
|
+
)}
|
|
82
|
+
</Button>
|
|
83
|
+
</PopoverTrigger>
|
|
84
|
+
<PopoverContent align="start" className="w-[min(780px,calc(100vw-2rem))] p-0">
|
|
85
|
+
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
|
86
|
+
<p className="text-sm font-semibold">Filters</p>
|
|
87
|
+
{filters.length > 0 && (
|
|
88
|
+
<Button variant="ghost" size="sm" onClick={onClear}>Clear all</Button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="space-y-1 px-3 py-3">
|
|
93
|
+
{filters.length === 0 ? (
|
|
94
|
+
<PropertyPicker fields={fields} onPick={onAdd} />
|
|
95
|
+
) : (
|
|
96
|
+
filters.map((rule, index) => (
|
|
97
|
+
<div key={rule.id}>
|
|
98
|
+
{index > 0 && <MatchToggle match={match} onChange={onMatchChange} />}
|
|
99
|
+
<FilterRuleRow
|
|
100
|
+
rule={rule}
|
|
101
|
+
fields={fields}
|
|
102
|
+
onChange={onChange}
|
|
103
|
+
onRemove={onRemove}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
))
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{filters.length > 0 && (
|
|
111
|
+
<div className="border-t p-2">
|
|
112
|
+
{adding ? (
|
|
113
|
+
<div className="space-y-1">
|
|
114
|
+
<PropertyPicker
|
|
115
|
+
fields={fields}
|
|
116
|
+
onPick={(field) => {
|
|
117
|
+
onAdd(field)
|
|
118
|
+
setAdding(false)
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
<Button
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="sm"
|
|
124
|
+
className="w-full justify-start"
|
|
125
|
+
onClick={() => setAdding(false)}
|
|
126
|
+
>
|
|
127
|
+
Cancel
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="sm"
|
|
134
|
+
className="w-full justify-start gap-2"
|
|
135
|
+
onClick={() => setAdding(true)}
|
|
136
|
+
>
|
|
137
|
+
<Plus className="size-4" />
|
|
138
|
+
Add filter
|
|
139
|
+
</Button>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</PopoverContent>
|
|
144
|
+
</Popover>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function PropertyPicker({
|
|
149
|
+
fields,
|
|
150
|
+
onPick,
|
|
151
|
+
}: {
|
|
152
|
+
fields: readonly FilterField[]
|
|
153
|
+
onPick: (field: string) => void
|
|
154
|
+
}) {
|
|
155
|
+
return (
|
|
156
|
+
<div>
|
|
157
|
+
<p className="px-2 pb-1 pt-1 text-xs font-medium text-muted-foreground">Filter by</p>
|
|
158
|
+
<div className="grid gap-0.5">
|
|
159
|
+
{fields.map((field) => {
|
|
160
|
+
const Icon = fieldIcons[field.type]
|
|
161
|
+
return (
|
|
162
|
+
<button
|
|
163
|
+
key={field.id}
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={() => onPick(field.id)}
|
|
166
|
+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
|
167
|
+
>
|
|
168
|
+
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
169
|
+
<span className="flex-1 truncate">{field.label}</span>
|
|
170
|
+
<span className="text-xs text-muted-foreground">{fieldTypeLabels[field.type]}</span>
|
|
171
|
+
</button>
|
|
172
|
+
)
|
|
173
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function MatchToggle({
|
|
180
|
+
match,
|
|
181
|
+
onChange,
|
|
182
|
+
}: {
|
|
183
|
+
match: FilterMatch
|
|
184
|
+
onChange: (match: FilterMatch) => void
|
|
185
|
+
}) {
|
|
186
|
+
return (
|
|
187
|
+
<DropdownMenu>
|
|
188
|
+
<DropdownMenuTrigger asChild>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
aria-label={`Match ${match === 'and' ? 'all' : 'any'} filters`}
|
|
192
|
+
className="my-1 ml-2 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
193
|
+
>
|
|
194
|
+
{match === 'and' ? 'And' : 'Or'}
|
|
195
|
+
<ChevronDown className="size-3" />
|
|
196
|
+
</button>
|
|
197
|
+
</DropdownMenuTrigger>
|
|
198
|
+
<DropdownMenuContent align="start" className="w-72">
|
|
199
|
+
<DropdownMenuRadioGroup
|
|
200
|
+
value={match}
|
|
201
|
+
onValueChange={(value) => onChange(value as FilterMatch)}
|
|
202
|
+
>
|
|
203
|
+
<DropdownMenuRadioItem value="and">
|
|
204
|
+
And
|
|
205
|
+
<span className="text-muted-foreground">— all filters must match</span>
|
|
206
|
+
</DropdownMenuRadioItem>
|
|
207
|
+
<DropdownMenuRadioItem value="or">
|
|
208
|
+
Or
|
|
209
|
+
<span className="text-muted-foreground">— at least one must match</span>
|
|
210
|
+
</DropdownMenuRadioItem>
|
|
211
|
+
</DropdownMenuRadioGroup>
|
|
212
|
+
</DropdownMenuContent>
|
|
213
|
+
</DropdownMenu>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function FilterRuleRow({
|
|
218
|
+
rule,
|
|
219
|
+
fields,
|
|
220
|
+
onChange,
|
|
221
|
+
onRemove,
|
|
222
|
+
}: {
|
|
223
|
+
rule: FilterRule
|
|
224
|
+
fields: readonly FilterField[]
|
|
225
|
+
onChange: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) => void
|
|
226
|
+
onRemove: (id: string) => void
|
|
227
|
+
}) {
|
|
228
|
+
const field = fields.find((candidate) => candidate.id === rule.field) ?? fields[0]
|
|
229
|
+
if (!field) return null
|
|
230
|
+
|
|
231
|
+
const needsValue = rule.operator !== 'isEmpty' && rule.operator !== 'isNotEmpty'
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2">
|
|
235
|
+
<Select
|
|
236
|
+
value={rule.field}
|
|
237
|
+
onValueChange={(value) => {
|
|
238
|
+
const nextField = fields.find((candidate) => candidate.id === value)
|
|
239
|
+
if (!nextField) return
|
|
240
|
+
onChange(rule.id, {
|
|
241
|
+
field: nextField.id,
|
|
242
|
+
operator: defaultOperatorByType[nextField.type],
|
|
243
|
+
value: [],
|
|
244
|
+
})
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
<SelectTrigger size="sm" aria-label="Filter field" className="w-[132px]">
|
|
248
|
+
<SelectValue />
|
|
249
|
+
</SelectTrigger>
|
|
250
|
+
<SelectContent>
|
|
251
|
+
{fields.map((candidate) => (
|
|
252
|
+
<SelectItem key={candidate.id} value={candidate.id}>{candidate.label}</SelectItem>
|
|
253
|
+
))}
|
|
254
|
+
</SelectContent>
|
|
255
|
+
</Select>
|
|
256
|
+
|
|
257
|
+
<Select
|
|
258
|
+
value={rule.operator}
|
|
259
|
+
onValueChange={(value) =>
|
|
260
|
+
onChange(rule.id, {
|
|
261
|
+
operator: value as FilterOperatorId,
|
|
262
|
+
value: value === 'between' ? [] : rule.value,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
>
|
|
266
|
+
<SelectTrigger size="sm" aria-label="Filter operator" className="w-[172px]">
|
|
267
|
+
<SelectValue />
|
|
268
|
+
</SelectTrigger>
|
|
269
|
+
<SelectContent>
|
|
270
|
+
{field.operators.map((operator) => (
|
|
271
|
+
<SelectItem key={operator} value={operator}>{operatorLabels[operator]}</SelectItem>
|
|
272
|
+
))}
|
|
273
|
+
</SelectContent>
|
|
274
|
+
</Select>
|
|
275
|
+
|
|
276
|
+
<div className="min-w-[140px] flex-1">
|
|
277
|
+
{needsValue ? (
|
|
278
|
+
<FilterValueEditor
|
|
279
|
+
field={field}
|
|
280
|
+
rule={rule}
|
|
281
|
+
onChange={(value) => onChange(rule.id, { value })}
|
|
282
|
+
/>
|
|
283
|
+
) : (
|
|
284
|
+
<span className="px-2 text-sm text-muted-foreground">—</span>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<Button
|
|
289
|
+
variant="ghost"
|
|
290
|
+
size="icon-sm"
|
|
291
|
+
aria-label="Remove filter"
|
|
292
|
+
onClick={() => onRemove(rule.id)}
|
|
293
|
+
>
|
|
294
|
+
<X className="size-4" />
|
|
295
|
+
</Button>
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function FilterValueEditor({
|
|
301
|
+
field,
|
|
302
|
+
rule,
|
|
303
|
+
onChange,
|
|
304
|
+
}: {
|
|
305
|
+
field: FilterField
|
|
306
|
+
rule: FilterRule
|
|
307
|
+
onChange: (value: string[]) => void
|
|
308
|
+
}) {
|
|
309
|
+
if (field.type === 'number') {
|
|
310
|
+
if (rule.operator === 'between') {
|
|
311
|
+
return (
|
|
312
|
+
<div className="flex items-center gap-2">
|
|
313
|
+
<Input
|
|
314
|
+
type="number"
|
|
315
|
+
inputMode="decimal"
|
|
316
|
+
aria-label="Minimum value"
|
|
317
|
+
placeholder="Min"
|
|
318
|
+
className="h-8"
|
|
319
|
+
value={rule.value[0] ?? ''}
|
|
320
|
+
onChange={(event) => onChange([event.target.value, rule.value[1] ?? ''])}
|
|
321
|
+
/>
|
|
322
|
+
<span className="text-xs text-muted-foreground">and</span>
|
|
323
|
+
<Input
|
|
324
|
+
type="number"
|
|
325
|
+
inputMode="decimal"
|
|
326
|
+
aria-label="Maximum value"
|
|
327
|
+
placeholder="Max"
|
|
328
|
+
className="h-8"
|
|
329
|
+
value={rule.value[1] ?? ''}
|
|
330
|
+
onChange={(event) => onChange([rule.value[0] ?? '', event.target.value])}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<Input
|
|
338
|
+
type="number"
|
|
339
|
+
inputMode="decimal"
|
|
340
|
+
aria-label={`${field.label} value`}
|
|
341
|
+
placeholder={field.placeholder ?? 'Value'}
|
|
342
|
+
className="h-8"
|
|
343
|
+
value={rule.value[0] ?? ''}
|
|
344
|
+
onChange={(event) => onChange([event.target.value])}
|
|
345
|
+
/>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (field.type === 'text') {
|
|
350
|
+
return (
|
|
351
|
+
<Input
|
|
352
|
+
aria-label={`${field.label} value`}
|
|
353
|
+
placeholder={field.placeholder ?? 'Type a value…'}
|
|
354
|
+
className="h-8"
|
|
355
|
+
value={rule.value[0] ?? ''}
|
|
356
|
+
onChange={(event) => onChange([event.target.value])}
|
|
357
|
+
/>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return <ValuePicker field={field} selected={rule.value} onChange={onChange} />
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function ValuePicker({
|
|
365
|
+
field,
|
|
366
|
+
selected,
|
|
367
|
+
onChange,
|
|
368
|
+
}: {
|
|
369
|
+
field: FilterField
|
|
370
|
+
selected: readonly string[]
|
|
371
|
+
onChange: (value: string[]) => void
|
|
372
|
+
}) {
|
|
373
|
+
const [open, setOpen] = useState(false)
|
|
374
|
+
const [query, setQuery] = useState('')
|
|
375
|
+
|
|
376
|
+
const options = field.options ?? []
|
|
377
|
+
const showSearch = options.length > 8
|
|
378
|
+
const normalizedQuery = query.trim().toLowerCase()
|
|
379
|
+
const visibleOptions = normalizedQuery
|
|
380
|
+
? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
|
|
381
|
+
: options
|
|
382
|
+
const selectedSet = new Set(selected)
|
|
383
|
+
|
|
384
|
+
const label = selected.length === 0
|
|
385
|
+
? 'Select values…'
|
|
386
|
+
: selected.length === 1
|
|
387
|
+
? options.find((option) => option.value === selected[0])?.label ?? selected[0]!
|
|
388
|
+
: `${selected.length} selected`
|
|
389
|
+
|
|
390
|
+
const toggle = (value: string) => {
|
|
391
|
+
onChange(
|
|
392
|
+
selectedSet.has(value)
|
|
393
|
+
? selected.filter((item) => item !== value)
|
|
394
|
+
: [...selected, value],
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<Popover
|
|
400
|
+
open={open}
|
|
401
|
+
onOpenChange={(nextOpen) => {
|
|
402
|
+
setOpen(nextOpen)
|
|
403
|
+
if (!nextOpen) setQuery('')
|
|
404
|
+
}}
|
|
405
|
+
>
|
|
406
|
+
<PopoverTrigger asChild>
|
|
407
|
+
<Button
|
|
408
|
+
variant="outline"
|
|
409
|
+
size="sm"
|
|
410
|
+
className={cn(
|
|
411
|
+
'h-8 w-full justify-between font-normal',
|
|
412
|
+
selected.length === 0 && 'text-muted-foreground',
|
|
413
|
+
)}
|
|
414
|
+
>
|
|
415
|
+
<span className="truncate">{label}</span>
|
|
416
|
+
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
|
417
|
+
</Button>
|
|
418
|
+
</PopoverTrigger>
|
|
419
|
+
<PopoverContent align="start" className="w-64 p-0">
|
|
420
|
+
{showSearch && (
|
|
421
|
+
<div className="border-b p-2">
|
|
422
|
+
<Input
|
|
423
|
+
autoFocus
|
|
424
|
+
value={query}
|
|
425
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
426
|
+
placeholder="Search values…"
|
|
427
|
+
aria-label={`Search ${field.label} values`}
|
|
428
|
+
className="h-8"
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
<div className="max-h-64 overflow-y-auto p-1">
|
|
433
|
+
{visibleOptions.length === 0 ? (
|
|
434
|
+
<p className="px-2 py-6 text-center text-sm text-muted-foreground">No matching values</p>
|
|
435
|
+
) : (
|
|
436
|
+
visibleOptions.map((option) => {
|
|
437
|
+
const checked = selectedSet.has(option.value)
|
|
438
|
+
return (
|
|
439
|
+
<div
|
|
440
|
+
key={option.value}
|
|
441
|
+
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
|
442
|
+
onClick={() => toggle(option.value)}
|
|
443
|
+
>
|
|
444
|
+
<Checkbox
|
|
445
|
+
checked={checked}
|
|
446
|
+
aria-label={option.label}
|
|
447
|
+
onClick={(event) => event.stopPropagation()}
|
|
448
|
+
onCheckedChange={() => toggle(option.value)}
|
|
449
|
+
/>
|
|
450
|
+
<span className="truncate">{option.label}</span>
|
|
451
|
+
</div>
|
|
452
|
+
)
|
|
453
|
+
})
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
{selected.length > 0 && (
|
|
457
|
+
<div className="border-t p-1">
|
|
458
|
+
<Button
|
|
459
|
+
variant="ghost"
|
|
460
|
+
size="sm"
|
|
461
|
+
className="w-full justify-start"
|
|
462
|
+
onClick={() => onChange([])}
|
|
463
|
+
>
|
|
464
|
+
Clear selection
|
|
465
|
+
</Button>
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
</PopoverContent>
|
|
469
|
+
</Popover>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
2
|
+
import rehypeKatex from 'rehype-katex'
|
|
3
|
+
import remarkGfm from 'remark-gfm'
|
|
4
|
+
import remarkMath from 'remark-math'
|
|
5
|
+
import { cn } from '../lib/utils.ts'
|
|
6
|
+
|
|
7
|
+
const components: Components = {
|
|
8
|
+
a: ({ ...props }) => <a className="font-medium text-green-800 underline decoration-green-300 underline-offset-2 hover:text-green-950" target="_blank" rel="noreferrer" {...props} />,
|
|
9
|
+
blockquote: ({ ...props }) => <blockquote className="my-4 border-l-2 border-gray-300 pl-4 text-gray-700" {...props} />,
|
|
10
|
+
code: ({ className, children, ...props }) => {
|
|
11
|
+
const isBlock = className?.startsWith('language-') ?? false
|
|
12
|
+
return <code className={cn(isBlock ? 'font-mono text-sm' : 'rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900', className)} {...props}>{children}</code>
|
|
13
|
+
},
|
|
14
|
+
h1: ({ ...props }) => <h1 className="mb-3 mt-6 text-2xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
15
|
+
h2: ({ ...props }) => <h2 className="mb-3 mt-6 text-xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
16
|
+
h3: ({ ...props }) => <h3 className="mb-2 mt-5 text-lg font-semibold text-gray-950 first:mt-0" {...props} />,
|
|
17
|
+
hr: () => <hr className="my-5 border-gray-200" />,
|
|
18
|
+
img: ({ ...props }) => <img className="my-4 h-auto max-w-full rounded-md border border-gray-200" loading="lazy" {...props} />,
|
|
19
|
+
li: ({ ...props }) => <li className="my-1 pl-1" {...props} />,
|
|
20
|
+
ol: ({ ...props }) => <ol className="my-3 list-decimal space-y-1 pl-6" {...props} />,
|
|
21
|
+
p: ({ ...props }) => <p className="my-3 whitespace-pre-wrap leading-7 first:mt-0 last:mb-0" {...props} />,
|
|
22
|
+
pre: ({ children, ...props }) => <pre className="my-4 overflow-x-auto rounded-lg bg-gray-950 p-4 text-gray-50" {...props}>{children}</pre>,
|
|
23
|
+
table: ({ children, ...props }) => <div className="my-4 overflow-x-auto"><table className="w-full border-collapse text-left text-sm" {...props}>{children}</table></div>,
|
|
24
|
+
tbody: ({ ...props }) => <tbody className="divide-y divide-gray-200" {...props} />,
|
|
25
|
+
td: ({ ...props }) => <td className="border border-gray-200 px-3 py-2 align-top" {...props} />,
|
|
26
|
+
th: ({ ...props }) => <th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-semibold" {...props} />,
|
|
27
|
+
ul: ({ ...props }) => <ul className="my-3 list-disc space-y-1 pl-6" {...props} />,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const markdownCode = /```[\s\S]*?```|~~~[\s\S]*?~~~|(`+)(?!`)[\s\S]*?\1(?!`)/g
|
|
31
|
+
|
|
32
|
+
const mapOutsideCode = (content: string, transform: (text: string) => string) => {
|
|
33
|
+
let result = ''
|
|
34
|
+
let lastIndex = 0
|
|
35
|
+
for (const match of content.matchAll(markdownCode)) {
|
|
36
|
+
const index = match.index ?? 0
|
|
37
|
+
result += transform(content.slice(lastIndex, index)) + match[0]
|
|
38
|
+
lastIndex = index + match[0].length
|
|
39
|
+
}
|
|
40
|
+
return result + transform(content.slice(lastIndex))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// remark-math uses $...$ inline and $$...$$ on separate lines for display math.
|
|
44
|
+
// Convert legacy LaTeX delimiters and same-line display expressions to those forms.
|
|
45
|
+
const normalizeMathDelimiters = (content: string) => mapOutsideCode(content, (text) => text
|
|
46
|
+
.replace(/\\\[([\s\S]+?)\\\]/g, (_match, formula: string) => `\n$$\n${formula.trim()}\n$$\n`)
|
|
47
|
+
.replace(/\$\$([\s\S]+?)\$\$/g, (_match, formula: string) => `\n$$\n${formula.trim()}\n$$\n`)
|
|
48
|
+
.replace(/\\\(([\s\S]+?)\\\)/g, (_match, formula: string) => `$${formula}$`))
|
|
49
|
+
|
|
50
|
+
const isPipeRow = (line: string) => line.trimStart().startsWith('|')
|
|
51
|
+
|
|
52
|
+
const isDelimiterRow = (line: string) => {
|
|
53
|
+
const trimmed = line.trim()
|
|
54
|
+
return trimmed.startsWith('|') && trimmed.includes('---')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const normalizeGfmTables = (content: string) => {
|
|
58
|
+
const parts = content.split(/(```[\s\S]*?```)/g)
|
|
59
|
+
return parts.map((part, index) => {
|
|
60
|
+
if (index % 2 === 1) return part
|
|
61
|
+
const lines = part.split('\n')
|
|
62
|
+
const result: string[] = []
|
|
63
|
+
let tableHasDelimiter = false
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i]
|
|
66
|
+
if (line.trim() === '') {
|
|
67
|
+
const prev = [...result].reverse().find((item) => item.trim() !== '') ?? ''
|
|
68
|
+
let next = ''
|
|
69
|
+
let afterNext = ''
|
|
70
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
71
|
+
if (lines[j].trim() !== '') {
|
|
72
|
+
next = lines[j]
|
|
73
|
+
for (let k = j + 1; k < lines.length; k++) {
|
|
74
|
+
if (lines[k].trim() !== '') {
|
|
75
|
+
afterNext = lines[k]
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (isPipeRow(prev) && isPipeRow(next)) {
|
|
83
|
+
const startsNewTable = tableHasDelimiter && !isDelimiterRow(prev) && !isDelimiterRow(next) && isDelimiterRow(afterNext)
|
|
84
|
+
if (!startsNewTable) continue
|
|
85
|
+
if (result.length > 0 && result[result.length - 1].trim() === '') continue
|
|
86
|
+
}
|
|
87
|
+
result.push(line)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (isPipeRow(line)) {
|
|
91
|
+
if (isDelimiterRow(line)) tableHasDelimiter = true
|
|
92
|
+
} else {
|
|
93
|
+
tableHasDelimiter = false
|
|
94
|
+
}
|
|
95
|
+
result.push(line)
|
|
96
|
+
}
|
|
97
|
+
return result.join('\n')
|
|
98
|
+
}).join('')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function MarkdownContent({ content, className }: { content: string; className?: string }) {
|
|
102
|
+
return (
|
|
103
|
+
<div className={cn('min-w-0 text-base leading-7 text-gray-800', className)}>
|
|
104
|
+
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
|
|
105
|
+
{normalizeGfmTables(normalizeMathDelimiters(content))}
|
|
106
|
+
</ReactMarkdown>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|