question-bank-framework 0.7.1 → 0.9.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 +22 -7
- package/framework/components/app-shell.tsx +40 -26
- package/framework/components/exam-timer.tsx +134 -0
- 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 +234 -0
- package/framework/components/ui/tooltip.tsx +65 -0
- package/framework/index.ts +4 -0
- package/framework/lib/exam-timer.ts +115 -0
- package/framework/styles/theme.css +66 -28
- package/package.json +4 -1
- package/template/src/routes/__root.tsx +1 -1
- package/template/src/routes/styles.css +0 -4
- package/template/src/routes/timer.tsx +4 -0
package/README.md
CHANGED
|
@@ -26,19 +26,23 @@ Your app owns:
|
|
|
26
26
|
- Thin TanStack Start routes in `src/routes/` (see `template/src/routes/`)
|
|
27
27
|
wrapping `<BankProvider bank={bank}>` + `<AppShell brand>` + framework pages.
|
|
28
28
|
- `src/routes/styles.css` (scaffolded by `qb init`, imported as `?url` from
|
|
29
|
-
`__root.tsx`)
|
|
30
|
-
`node_modules` by default, so without this none of the grid/badge/filter
|
|
31
|
-
utilities are generated and the app renders unstyled:
|
|
29
|
+
`__root.tsx`):
|
|
32
30
|
|
|
33
31
|
```css
|
|
34
32
|
@import 'tailwindcss';
|
|
35
33
|
@import 'question-bank-framework/framework/styles/theme.css';
|
|
36
|
-
@source '../node_modules/question-bank-framework'; /* relative to this file */
|
|
37
34
|
```
|
|
38
35
|
|
|
39
|
-
`theme.css`
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
`theme.css` is the design system: shadcn/ui neutral tokens, Inter Variable
|
|
37
|
+
(weight and optical-size axes, with `font-optical-sizing: auto`), tw-animate-css,
|
|
38
|
+
and the KaTeX stylesheet from the framework's own `katex`. It also registers the
|
|
39
|
+
framework's components as a Tailwind source, so you don't need an `@source` line.
|
|
40
|
+
Don't import `katex/dist/katex.min.css` yourself — a mismatched KaTeX version
|
|
41
|
+
breaks math layout (0.19 renamed its CSS classes).
|
|
42
|
+
|
|
43
|
+
UI components in `framework/components/ui/` are shadcn/ui on Base UI
|
|
44
|
+
(base-nova style). Style everything with Tailwind utilities and the theme tokens
|
|
45
|
+
(`bg-background`, `text-muted-foreground`, `border`, …), not raw colours.
|
|
42
46
|
|
|
43
47
|
Text columns sort number-aware ("Q2" before "Q10"). New quizzes keep the
|
|
44
48
|
table's current sort order, so to get source order add a numeric field (e.g.
|
|
@@ -48,6 +52,17 @@ Pass `hideTagsColumn: true` to `defineBank` when tags merely duplicate
|
|
|
48
52
|
dedicated fields (every tag equals a topic/type value already in its own
|
|
49
53
|
column). Tags still feed free-text search.
|
|
50
54
|
|
|
55
|
+
## Mock-exam timer
|
|
56
|
+
|
|
57
|
+
The header has a **Timer** link to `/timer` (add `src/routes/timer.tsx`
|
|
58
|
+
rendering `<ExamTimerPage />`, as in the template). Enter the exam's start
|
|
59
|
+
time (e.g. 08:30) and reading/writing minutes, then press Start: the top right
|
|
60
|
+
of the header shows the mock exam time, starting at 08:30, with a book icon
|
|
61
|
+
during reading time and a pen during writing time. It updates once a minute.
|
|
62
|
+
Toasts announce reading time over, 30 and 10 minutes remaining, and exam over;
|
|
63
|
+
the clock disappears once the exam is over. The timer lives in localStorage,
|
|
64
|
+
so it survives reloads and shows in every open tab.
|
|
65
|
+
|
|
51
66
|
## Sync (Cloudflare Durable Objects)
|
|
52
67
|
|
|
53
68
|
Without `sync`, quizzes and question statuses live only in the browser's
|
|
@@ -4,51 +4,65 @@
|
|
|
4
4
|
import { Link, useRouterState } from '@tanstack/react-router';
|
|
5
5
|
import type { ReactNode } from 'react';
|
|
6
6
|
import { cn } from '../lib/utils.ts';
|
|
7
|
+
import { ExamTimerBadge } from './exam-timer.tsx';
|
|
8
|
+
import { Toaster } from './ui/toast';
|
|
7
9
|
|
|
8
10
|
export function AppShell({
|
|
9
11
|
children,
|
|
10
12
|
brand,
|
|
11
13
|
homeLabel = 'Question bank',
|
|
12
14
|
quizzesLabel = 'My quizzes',
|
|
15
|
+
timerLabel = 'Timer',
|
|
13
16
|
}: {
|
|
14
17
|
children: ReactNode;
|
|
15
18
|
brand: string;
|
|
16
19
|
homeLabel?: string;
|
|
17
20
|
quizzesLabel?: string;
|
|
21
|
+
timerLabel?: string;
|
|
18
22
|
}) {
|
|
19
23
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
20
24
|
const navigation = [
|
|
21
25
|
{ label: homeLabel, to: '/' },
|
|
22
26
|
{ label: quizzesLabel, to: '/quizzes' },
|
|
27
|
+
{ label: timerLabel, to: '/timer' },
|
|
23
28
|
] as const;
|
|
24
29
|
const isQuestionBank = pathname === '/';
|
|
25
30
|
|
|
26
31
|
return (
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
32
|
+
<Toaster>
|
|
33
|
+
<div className={cn('flex flex-col bg-background', isQuestionBank ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
|
|
34
|
+
<header className="sticky top-0 z-20 shrink-0 border-b bg-background/80 backdrop-blur-sm">
|
|
35
|
+
<div className="flex h-14 items-center gap-6 px-6">
|
|
36
|
+
<Link to="/" className="text-sm font-semibold text-foreground">
|
|
37
|
+
{brand}
|
|
38
|
+
</Link>
|
|
39
|
+
<nav className="flex items-center gap-1" aria-label="Main navigation">
|
|
40
|
+
{navigation.map(({ label, to }) => {
|
|
41
|
+
const active = to === '/' ? pathname === '/' : pathname.startsWith(to);
|
|
42
|
+
return (
|
|
43
|
+
<Link
|
|
44
|
+
key={to}
|
|
45
|
+
to={to}
|
|
46
|
+
aria-current={active ? 'page' : undefined}
|
|
47
|
+
className={cn(
|
|
48
|
+
'rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none',
|
|
49
|
+
active && 'bg-muted text-foreground',
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{label}
|
|
53
|
+
</Link>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</nav>
|
|
57
|
+
<div className="ml-auto">
|
|
58
|
+
<ExamTimerBadge />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</header>
|
|
62
|
+
<main className={cn('w-full flex-1', isQuestionBank ? 'flex min-h-0 flex-col overflow-hidden' : 'mx-auto max-w-5xl px-6 py-10')}>
|
|
63
|
+
{children}
|
|
64
|
+
</main>
|
|
65
|
+
</div>
|
|
66
|
+
</Toaster>
|
|
53
67
|
);
|
|
54
68
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Mock-exam clock: <ExamTimerBadge> shows the mock time in the header while an
|
|
2
|
+
// exam is running; <ExamTimerPage> (the /timer route) is the only place to start it.
|
|
3
|
+
// Framework-owned: eject with `qb reveal framework/components/exam-timer.tsx`.
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
|
6
|
+
import { BookOpen, PenLine } from 'lucide-react';
|
|
7
|
+
import { useBank } from '../../core/config.tsx';
|
|
8
|
+
import { Button } from './ui/button';
|
|
9
|
+
import { Card, CardContent, CardFooter } from './ui/card';
|
|
10
|
+
import { Field, FieldGroup, FieldLabel } from './ui/field';
|
|
11
|
+
import { Input } from './ui/input';
|
|
12
|
+
import { toast } from './ui/toast';
|
|
13
|
+
import { examState, formatClock, MINUTE, setExamTimer, useExamNow, useExamTimer, type ExamTimer } from '../lib/exam-timer.ts';
|
|
14
|
+
|
|
15
|
+
/** Moments to announce, in minutes after Start. */
|
|
16
|
+
const milestones = ({ readingMinutes: reading, writingMinutes: writing }: ExamTimer) => {
|
|
17
|
+
const end = reading + writing;
|
|
18
|
+
return [
|
|
19
|
+
...(reading > 0 ? [{ at: reading, title: 'Reading time over' }] : []),
|
|
20
|
+
...(writing > 30 ? [{ at: end - 30, title: '30 minutes remaining' }] : []),
|
|
21
|
+
...(writing > 10 ? [{ at: end - 10, title: '10 minutes remaining' }] : []),
|
|
22
|
+
{ at: end, title: 'Exam over' },
|
|
23
|
+
];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ExamTimerBadge() {
|
|
27
|
+
const { namespace } = useBank();
|
|
28
|
+
const timer = useExamTimer(namespace);
|
|
29
|
+
const now = useExamNow(timer);
|
|
30
|
+
const state = timer ? examState(timer, now) : null;
|
|
31
|
+
|
|
32
|
+
// Toast each milestone crossed since the last tick (not ones already past on load).
|
|
33
|
+
const last = useRef<{ startedAt: number; elapsed: number } | null>(null);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!timer || !state) return;
|
|
36
|
+
const previous = last.current;
|
|
37
|
+
if (previous?.startedAt === timer.startedAt) {
|
|
38
|
+
for (const { at, title } of milestones(timer)) {
|
|
39
|
+
if (previous.elapsed < at * MINUTE && at * MINUTE <= state.elapsed) toast.add({ title });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
last.current = { startedAt: timer.startedAt, elapsed: state.elapsed };
|
|
43
|
+
}, [timer?.startedAt, state?.elapsed]);
|
|
44
|
+
|
|
45
|
+
if (!state || state.phase === 'over') return null;
|
|
46
|
+
const Icon = state.phase === 'reading' ? BookOpen : PenLine;
|
|
47
|
+
return (
|
|
48
|
+
<span
|
|
49
|
+
className="flex items-center gap-1.5 text-sm font-medium tabular-nums text-foreground"
|
|
50
|
+
title={state.phase === 'reading' ? 'Reading time' : 'Writing time'}
|
|
51
|
+
>
|
|
52
|
+
<Icon aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
53
|
+
<span className="sr-only">{state.phase === 'reading' ? 'Reading time' : 'Writing time'}, mock exam time </span>
|
|
54
|
+
{state.clock}
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parseClock = (time: string) => {
|
|
60
|
+
const [hours, minutes] = time.split(':').map(Number);
|
|
61
|
+
return (hours ?? 0) * 60 + (minutes ?? 0);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function ExamTimerPage({
|
|
65
|
+
defaultStart = '08:30',
|
|
66
|
+
defaultReadingMinutes = 15,
|
|
67
|
+
defaultWritingMinutes = 180,
|
|
68
|
+
}: {
|
|
69
|
+
defaultStart?: string;
|
|
70
|
+
defaultReadingMinutes?: number;
|
|
71
|
+
defaultWritingMinutes?: number;
|
|
72
|
+
}) {
|
|
73
|
+
const { namespace } = useBank();
|
|
74
|
+
const timer = useExamTimer(namespace);
|
|
75
|
+
const now = useExamNow(timer);
|
|
76
|
+
const running = timer !== null && examState(timer, now).phase !== 'over';
|
|
77
|
+
const [start, setStart] = useState(defaultStart);
|
|
78
|
+
const [reading, setReading] = useState(String(defaultReadingMinutes));
|
|
79
|
+
const [writing, setWriting] = useState(String(defaultWritingMinutes));
|
|
80
|
+
|
|
81
|
+
// Show the saved settings once they're read (after hydration, or when another tab changes them).
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!timer) return;
|
|
84
|
+
setStart(formatClock(timer.startMinutes));
|
|
85
|
+
setReading(String(timer.readingMinutes));
|
|
86
|
+
setWriting(String(timer.writingMinutes));
|
|
87
|
+
}, [timer?.startedAt]);
|
|
88
|
+
|
|
89
|
+
const submit = (event: FormEvent) => {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
setExamTimer(namespace, {
|
|
92
|
+
startMinutes: parseClock(start),
|
|
93
|
+
startedAt: Date.now(),
|
|
94
|
+
readingMinutes: Math.max(0, Number(reading) || 0),
|
|
95
|
+
writingMinutes: Math.max(0, Number(writing) || 0),
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="mx-auto grid w-full max-w-md gap-8">
|
|
101
|
+
<h1 className="text-2xl font-semibold">Timer</h1>
|
|
102
|
+
<Card>
|
|
103
|
+
<form onSubmit={submit} className="contents">
|
|
104
|
+
<CardContent>
|
|
105
|
+
<FieldGroup>
|
|
106
|
+
<Field>
|
|
107
|
+
<FieldLabel htmlFor="exam-start">Exam start time</FieldLabel>
|
|
108
|
+
<Input id="exam-start" type="time" required value={start} onChange={(event) => setStart(event.target.value)} />
|
|
109
|
+
</Field>
|
|
110
|
+
<div className="grid grid-cols-2 gap-4">
|
|
111
|
+
<Field>
|
|
112
|
+
<FieldLabel htmlFor="exam-reading">Reading minutes</FieldLabel>
|
|
113
|
+
<Input id="exam-reading" type="number" min={0} required inputMode="numeric" className="tabular-nums" value={reading} onChange={(event) => setReading(event.target.value)} />
|
|
114
|
+
</Field>
|
|
115
|
+
<Field>
|
|
116
|
+
<FieldLabel htmlFor="exam-writing">Writing minutes</FieldLabel>
|
|
117
|
+
<Input id="exam-writing" type="number" min={0} required inputMode="numeric" className="tabular-nums" value={writing} onChange={(event) => setWriting(event.target.value)} />
|
|
118
|
+
</Field>
|
|
119
|
+
</div>
|
|
120
|
+
</FieldGroup>
|
|
121
|
+
</CardContent>
|
|
122
|
+
<CardFooter className="gap-2">
|
|
123
|
+
<Button type="submit">{running ? 'Restart' : 'Start'}</Button>
|
|
124
|
+
{running && (
|
|
125
|
+
<Button type="button" variant="ghost" onClick={() => setExamTimer(namespace, null)}>
|
|
126
|
+
Stop
|
|
127
|
+
</Button>
|
|
128
|
+
)}
|
|
129
|
+
</CardFooter>
|
|
130
|
+
</form>
|
|
131
|
+
</Card>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import { ChevronDown, Circle,
|
|
2
|
+
import { ChevronDown, Circle, Hash, ListFilter, Plus, Tags, Type, X, type LucideIcon } from 'lucide-react'
|
|
3
3
|
import { Badge } from './ui/badge'
|
|
4
4
|
import { Button } from './ui/button'
|
|
5
5
|
import { Checkbox } from './ui/checkbox'
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
} from '../lib/question-filter.ts'
|
|
32
32
|
import { cn } from '../lib/utils.ts'
|
|
33
33
|
|
|
34
|
-
const fieldIcons: Record<FilterFieldType,
|
|
34
|
+
const fieldIcons: Record<FilterFieldType, LucideIcon> = {
|
|
35
35
|
text: Type,
|
|
36
36
|
number: Hash,
|
|
37
37
|
select: Circle,
|
|
@@ -70,26 +70,24 @@ export function FilterBuilder({
|
|
|
70
70
|
if (!nextOpen) setAdding(false)
|
|
71
71
|
}}
|
|
72
72
|
>
|
|
73
|
-
<PopoverTrigger
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)}
|
|
82
|
-
</Button>
|
|
73
|
+
<PopoverTrigger render={<Button variant="outline" />}>
|
|
74
|
+
<ListFilter aria-hidden="true" data-icon="inline-start" className="text-muted-foreground" />
|
|
75
|
+
Filter
|
|
76
|
+
{filters.length > 0 && (
|
|
77
|
+
<Badge variant="secondary" className="min-w-5 px-1.5 tabular-nums">
|
|
78
|
+
{filters.length}
|
|
79
|
+
</Badge>
|
|
80
|
+
)}
|
|
83
81
|
</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
|
|
86
|
-
<p className="text-sm font-
|
|
82
|
+
<PopoverContent align="start" className="w-[min(780px,calc(100vw-2rem))] gap-0 p-0">
|
|
83
|
+
<div className="flex h-10 items-center justify-between gap-2 border-b px-3">
|
|
84
|
+
<p className="text-sm font-medium">Filters</p>
|
|
87
85
|
{filters.length > 0 && (
|
|
88
|
-
<Button variant="ghost" size="sm" onClick={onClear}>Clear all</Button>
|
|
86
|
+
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={onClear}>Clear all</Button>
|
|
89
87
|
)}
|
|
90
88
|
</div>
|
|
91
89
|
|
|
92
|
-
<div className="
|
|
90
|
+
<div className="flex flex-col p-2">
|
|
93
91
|
{filters.length === 0 ? (
|
|
94
92
|
<PropertyPicker fields={fields} onPick={onAdd} />
|
|
95
93
|
) : (
|
|
@@ -110,7 +108,7 @@ export function FilterBuilder({
|
|
|
110
108
|
{filters.length > 0 && (
|
|
111
109
|
<div className="border-t p-2">
|
|
112
110
|
{adding ? (
|
|
113
|
-
<div className="
|
|
111
|
+
<div className="flex flex-col gap-1">
|
|
114
112
|
<PropertyPicker
|
|
115
113
|
fields={fields}
|
|
116
114
|
onPick={(field) => {
|
|
@@ -131,10 +129,10 @@ export function FilterBuilder({
|
|
|
131
129
|
<Button
|
|
132
130
|
variant="ghost"
|
|
133
131
|
size="sm"
|
|
134
|
-
className="w-full justify-start
|
|
132
|
+
className="w-full justify-start text-muted-foreground"
|
|
135
133
|
onClick={() => setAdding(true)}
|
|
136
134
|
>
|
|
137
|
-
<Plus
|
|
135
|
+
<Plus aria-hidden="true" />
|
|
138
136
|
Add filter
|
|
139
137
|
</Button>
|
|
140
138
|
)}
|
|
@@ -154,8 +152,8 @@ function PropertyPicker({
|
|
|
154
152
|
}) {
|
|
155
153
|
return (
|
|
156
154
|
<div>
|
|
157
|
-
<p className="px-2
|
|
158
|
-
<div className="grid gap-
|
|
155
|
+
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">Filter by</p>
|
|
156
|
+
<div className="grid gap-px">
|
|
159
157
|
{fields.map((field) => {
|
|
160
158
|
const Icon = fieldIcons[field.type]
|
|
161
159
|
return (
|
|
@@ -163,9 +161,9 @@ function PropertyPicker({
|
|
|
163
161
|
key={field.id}
|
|
164
162
|
type="button"
|
|
165
163
|
onClick={() => onPick(field.id)}
|
|
166
|
-
className="flex items-center gap-2 rounded-md px-2
|
|
164
|
+
className="flex h-8 items-center gap-2 rounded-md px-2 text-left text-sm outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
167
165
|
>
|
|
168
|
-
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
166
|
+
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
169
167
|
<span className="flex-1 truncate">{field.label}</span>
|
|
170
168
|
<span className="text-xs text-muted-foreground">{fieldTypeLabels[field.type]}</span>
|
|
171
169
|
</button>
|
|
@@ -185,28 +183,31 @@ function MatchToggle({
|
|
|
185
183
|
}) {
|
|
186
184
|
return (
|
|
187
185
|
<DropdownMenu>
|
|
188
|
-
<DropdownMenuTrigger
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
186
|
+
<DropdownMenuTrigger
|
|
187
|
+
render={
|
|
188
|
+
<Button
|
|
189
|
+
variant="ghost"
|
|
190
|
+
size="xs"
|
|
191
|
+
aria-label={`Match ${match === 'and' ? 'all' : 'any'} filters`}
|
|
192
|
+
className="my-1 ml-1 text-muted-foreground"
|
|
193
|
+
/>
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
{match === 'and' ? 'And' : 'Or'}
|
|
197
|
+
<ChevronDown aria-hidden="true" data-icon="inline-end" />
|
|
197
198
|
</DropdownMenuTrigger>
|
|
198
|
-
<DropdownMenuContent align="start" className="w-
|
|
199
|
+
<DropdownMenuContent align="start" className="w-48">
|
|
199
200
|
<DropdownMenuRadioGroup
|
|
200
201
|
value={match}
|
|
201
202
|
onValueChange={(value) => onChange(value as FilterMatch)}
|
|
202
203
|
>
|
|
203
204
|
<DropdownMenuRadioItem value="and">
|
|
204
205
|
And
|
|
205
|
-
<span className="text-muted-foreground"
|
|
206
|
+
<span className="ml-auto text-xs text-muted-foreground">all must match</span>
|
|
206
207
|
</DropdownMenuRadioItem>
|
|
207
208
|
<DropdownMenuRadioItem value="or">
|
|
208
209
|
Or
|
|
209
|
-
<span className="text-muted-foreground"
|
|
210
|
+
<span className="ml-auto text-xs text-muted-foreground">any can match</span>
|
|
210
211
|
</DropdownMenuRadioItem>
|
|
211
212
|
</DropdownMenuRadioGroup>
|
|
212
213
|
</DropdownMenuContent>
|
|
@@ -231,10 +232,12 @@ function FilterRuleRow({
|
|
|
231
232
|
const needsValue = rule.operator !== 'isEmpty' && rule.operator !== 'isNotEmpty'
|
|
232
233
|
|
|
233
234
|
return (
|
|
234
|
-
<div className="flex flex-wrap items-center gap-2 rounded-lg border
|
|
235
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-2">
|
|
235
236
|
<Select
|
|
236
237
|
value={rule.field}
|
|
238
|
+
items={fields.map((candidate) => ({ value: candidate.id, label: candidate.label }))}
|
|
237
239
|
onValueChange={(value) => {
|
|
240
|
+
if (value === null) return
|
|
238
241
|
const nextField = fields.find((candidate) => candidate.id === value)
|
|
239
242
|
if (!nextField) return
|
|
240
243
|
onChange(rule.id, {
|
|
@@ -244,7 +247,7 @@ function FilterRuleRow({
|
|
|
244
247
|
})
|
|
245
248
|
}}
|
|
246
249
|
>
|
|
247
|
-
<SelectTrigger
|
|
250
|
+
<SelectTrigger aria-label="Filter field" className="w-32">
|
|
248
251
|
<SelectValue />
|
|
249
252
|
</SelectTrigger>
|
|
250
253
|
<SelectContent>
|
|
@@ -256,14 +259,16 @@ function FilterRuleRow({
|
|
|
256
259
|
|
|
257
260
|
<Select
|
|
258
261
|
value={rule.operator}
|
|
259
|
-
|
|
262
|
+
items={field.operators.map((operator) => ({ value: operator, label: operatorLabels[operator] }))}
|
|
263
|
+
onValueChange={(value) => {
|
|
264
|
+
if (value === null) return
|
|
260
265
|
onChange(rule.id, {
|
|
261
266
|
operator: value as FilterOperatorId,
|
|
262
267
|
value: value === 'between' ? [] : rule.value,
|
|
263
268
|
})
|
|
264
|
-
}
|
|
269
|
+
}}
|
|
265
270
|
>
|
|
266
|
-
<SelectTrigger
|
|
271
|
+
<SelectTrigger aria-label="Filter operator" className="w-44">
|
|
267
272
|
<SelectValue />
|
|
268
273
|
</SelectTrigger>
|
|
269
274
|
<SelectContent>
|
|
@@ -273,7 +278,7 @@ function FilterRuleRow({
|
|
|
273
278
|
</SelectContent>
|
|
274
279
|
</Select>
|
|
275
280
|
|
|
276
|
-
<div className="min-w-
|
|
281
|
+
<div className="min-w-36 flex-1">
|
|
277
282
|
{needsValue ? (
|
|
278
283
|
<FilterValueEditor
|
|
279
284
|
field={field}
|
|
@@ -287,11 +292,12 @@ function FilterRuleRow({
|
|
|
287
292
|
|
|
288
293
|
<Button
|
|
289
294
|
variant="ghost"
|
|
290
|
-
size="icon
|
|
295
|
+
size="icon"
|
|
291
296
|
aria-label="Remove filter"
|
|
297
|
+
className="text-muted-foreground"
|
|
292
298
|
onClick={() => onRemove(rule.id)}
|
|
293
299
|
>
|
|
294
|
-
<X
|
|
300
|
+
<X aria-hidden="true" />
|
|
295
301
|
</Button>
|
|
296
302
|
</div>
|
|
297
303
|
)
|
|
@@ -315,7 +321,6 @@ function FilterValueEditor({
|
|
|
315
321
|
inputMode="decimal"
|
|
316
322
|
aria-label="Minimum value"
|
|
317
323
|
placeholder="Min"
|
|
318
|
-
className="h-8"
|
|
319
324
|
value={rule.value[0] ?? ''}
|
|
320
325
|
onChange={(event) => onChange([event.target.value, rule.value[1] ?? ''])}
|
|
321
326
|
/>
|
|
@@ -325,7 +330,6 @@ function FilterValueEditor({
|
|
|
325
330
|
inputMode="decimal"
|
|
326
331
|
aria-label="Maximum value"
|
|
327
332
|
placeholder="Max"
|
|
328
|
-
className="h-8"
|
|
329
333
|
value={rule.value[1] ?? ''}
|
|
330
334
|
onChange={(event) => onChange([rule.value[0] ?? '', event.target.value])}
|
|
331
335
|
/>
|
|
@@ -339,7 +343,6 @@ function FilterValueEditor({
|
|
|
339
343
|
inputMode="decimal"
|
|
340
344
|
aria-label={`${field.label} value`}
|
|
341
345
|
placeholder={field.placeholder ?? 'Value'}
|
|
342
|
-
className="h-8"
|
|
343
346
|
value={rule.value[0] ?? ''}
|
|
344
347
|
onChange={(event) => onChange([event.target.value])}
|
|
345
348
|
/>
|
|
@@ -351,7 +354,6 @@ function FilterValueEditor({
|
|
|
351
354
|
<Input
|
|
352
355
|
aria-label={`${field.label} value`}
|
|
353
356
|
placeholder={field.placeholder ?? 'Type a value…'}
|
|
354
|
-
className="h-8"
|
|
355
357
|
value={rule.value[0] ?? ''}
|
|
356
358
|
onChange={(event) => onChange([event.target.value])}
|
|
357
359
|
/>
|
|
@@ -403,20 +405,21 @@ function ValuePicker({
|
|
|
403
405
|
if (!nextOpen) setQuery('')
|
|
404
406
|
}}
|
|
405
407
|
>
|
|
406
|
-
<PopoverTrigger
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
</
|
|
408
|
+
<PopoverTrigger
|
|
409
|
+
render={
|
|
410
|
+
<Button
|
|
411
|
+
variant="outline"
|
|
412
|
+
className={cn(
|
|
413
|
+
'w-full justify-between border-input font-normal',
|
|
414
|
+
selected.length === 0 && 'text-muted-foreground',
|
|
415
|
+
)}
|
|
416
|
+
/>
|
|
417
|
+
}
|
|
418
|
+
>
|
|
419
|
+
<span className="truncate">{label}</span>
|
|
420
|
+
<ChevronDown aria-hidden="true" className="text-muted-foreground" />
|
|
418
421
|
</PopoverTrigger>
|
|
419
|
-
<PopoverContent align="start" className="w-64 p-0">
|
|
422
|
+
<PopoverContent align="start" className="w-64 gap-0 p-0">
|
|
420
423
|
{showSearch && (
|
|
421
424
|
<div className="border-b p-2">
|
|
422
425
|
<Input
|
|
@@ -425,7 +428,6 @@ function ValuePicker({
|
|
|
425
428
|
onChange={(event) => setQuery(event.target.value)}
|
|
426
429
|
placeholder="Search values…"
|
|
427
430
|
aria-label={`Search ${field.label} values`}
|
|
428
|
-
className="h-8"
|
|
429
431
|
/>
|
|
430
432
|
</div>
|
|
431
433
|
)}
|
|
@@ -438,7 +440,7 @@ function ValuePicker({
|
|
|
438
440
|
return (
|
|
439
441
|
<div
|
|
440
442
|
key={option.value}
|
|
441
|
-
className="flex cursor-pointer items-center gap-2 rounded-
|
|
443
|
+
className="flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-sm hover:bg-muted/50"
|
|
442
444
|
onClick={() => toggle(option.value)}
|
|
443
445
|
>
|
|
444
446
|
<Checkbox
|
|
@@ -25,23 +25,23 @@ function MathFormula({ tex, display }: { tex: string; display: boolean }) {
|
|
|
25
25
|
|
|
26
26
|
// Every renderer drops react-markdown's `node` prop so it isn't forwarded to the DOM.
|
|
27
27
|
const components: Components = {
|
|
28
|
-
a: ({ node: _node, ...props }) => <a className="font-medium text-
|
|
29
|
-
blockquote: ({ node: _node, ...props }) => <blockquote className="my-4 border-l-2
|
|
28
|
+
a: ({ node: _node, ...props }) => <a className="font-medium text-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground" target="_blank" rel="noreferrer" {...props} />,
|
|
29
|
+
blockquote: ({ node: _node, ...props }) => <blockquote className="my-4 border-l-2 pl-4 text-muted-foreground" {...props} />,
|
|
30
30
|
code: ({ node: _node, className, children, ...props }) => {
|
|
31
31
|
if (hasClass(className, 'language-math')) {
|
|
32
32
|
return <MathFormula tex={textOf(children)} display={hasClass(className, 'math-display')} />
|
|
33
33
|
}
|
|
34
34
|
// Inline-code styling; fenced blocks reset it via the `pre` renderer below.
|
|
35
|
-
return <code className={cn('rounded bg-
|
|
35
|
+
return <code className={cn('rounded bg-muted px-1 py-0.5 font-mono text-[0.875em] break-words', className)} {...props}>{children}</code>
|
|
36
36
|
},
|
|
37
|
-
h1: ({ node: _node, ...props }) => <h1 className="mb-3 mt-6 text-
|
|
38
|
-
h2: ({ node: _node, ...props }) => <h2 className="mb-3 mt-6 text-
|
|
39
|
-
h3: ({ node: _node, ...props }) => <h3 className="mb-2 mt-5 text-
|
|
40
|
-
hr: () => <hr className="my-5
|
|
41
|
-
img: ({ node: _node, ...props }) => <img className="my-4 h-auto max-w-full rounded-
|
|
37
|
+
h1: ({ node: _node, ...props }) => <h1 className="mb-3 mt-6 text-xl font-semibold first:mt-0" {...props} />,
|
|
38
|
+
h2: ({ node: _node, ...props }) => <h2 className="mb-3 mt-6 text-lg font-semibold first:mt-0" {...props} />,
|
|
39
|
+
h3: ({ node: _node, ...props }) => <h3 className="mb-2 mt-5 text-base font-semibold first:mt-0" {...props} />,
|
|
40
|
+
hr: () => <hr className="my-5" />,
|
|
41
|
+
img: ({ node: _node, ...props }) => <img className="my-4 h-auto max-w-full rounded-lg border" loading="lazy" {...props} />,
|
|
42
42
|
li: ({ node: _node, ...props }) => <li className="my-1 pl-1" {...props} />,
|
|
43
43
|
ol: ({ node: _node, ...props }) => <ol className="my-3 list-decimal space-y-1 pl-6" {...props} />,
|
|
44
|
-
p: ({ node: _node, ...props }) => <p className="my-3 whitespace-pre-wrap
|
|
44
|
+
p: ({ node: _node, ...props }) => <p className="my-3 whitespace-pre-wrap first:mt-0 last:mb-0" {...props} />,
|
|
45
45
|
pre: ({ node, children, ...props }) => {
|
|
46
46
|
// remark-math emits display math as <pre><code class="language-math math-display">.
|
|
47
47
|
const first = node?.children.find((child) => child.type === 'element')
|
|
@@ -50,17 +50,17 @@ const components: Components = {
|
|
|
50
50
|
}
|
|
51
51
|
return (
|
|
52
52
|
<pre
|
|
53
|
-
className="my-4 overflow-x-auto rounded-lg bg-
|
|
53
|
+
className="my-4 overflow-x-auto rounded-lg bg-muted p-4 font-mono text-xs leading-5 [&_code]:rounded-none [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-xs [&_code]:break-normal"
|
|
54
54
|
{...props}
|
|
55
55
|
>
|
|
56
56
|
{children}
|
|
57
57
|
</pre>
|
|
58
58
|
)
|
|
59
59
|
},
|
|
60
|
-
table: ({ node: _node, children, ...props }) => <div className="my-4 overflow-x-auto"><table className="w-full border-collapse text-left text-sm" {...props}>{children}</table></div>,
|
|
61
|
-
tbody: ({ node: _node, ...props }) => <tbody className="divide-y
|
|
62
|
-
td: ({ node: _node, ...props }) => <td className="border
|
|
63
|
-
th: ({ node: _node, ...props }) => <th className="border
|
|
60
|
+
table: ({ node: _node, children, ...props }) => <div className="my-4 overflow-x-auto rounded-lg border"><table className="w-full border-collapse text-left text-sm [&_tr>*:first-child]:border-l-0 [&_tr>*:last-child]:border-r-0 [&_tr:first-child>*]:border-t-0 [&_tbody_tr:last-child>*]:border-b-0" {...props}>{children}</table></div>,
|
|
61
|
+
tbody: ({ node: _node, ...props }) => <tbody className="divide-y" {...props} />,
|
|
62
|
+
td: ({ node: _node, ...props }) => <td className="border px-3 py-2 align-top" {...props} />,
|
|
63
|
+
th: ({ node: _node, ...props }) => <th className="border bg-muted/50 px-3 py-2 text-left font-medium" {...props} />,
|
|
64
64
|
ul: ({ node: _node, ...props }) => <ul className="my-3 list-disc space-y-1 pl-6" {...props} />,
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -153,5 +153,5 @@ export function MarkdownContent({ content, className, inline = false }: { conten
|
|
|
153
153
|
</ReactMarkdown>
|
|
154
154
|
)
|
|
155
155
|
if (inline) return <span className={cn('min-w-0', className)}>{markdown}</span>
|
|
156
|
-
return <div className={cn(blockClassName, 'text-
|
|
156
|
+
return <div className={cn(blockClassName, 'text-[15px] leading-7 text-foreground', className)}>{markdown}</div>
|
|
157
157
|
}
|