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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// shadcn/ui toast (Base UI, base-nova). Framework-owned: eject with `qb reveal framework/components/ui/toast.tsx`.
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Toast as ToastPrimitive } from "@base-ui/react/toast"
|
|
5
|
+
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-react"
|
|
6
|
+
import { cn } from "../../lib/utils.ts"
|
|
7
|
+
|
|
8
|
+
import { Button } from "./button"
|
|
9
|
+
|
|
10
|
+
const toast = ToastPrimitive.createToastManager()
|
|
11
|
+
|
|
12
|
+
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
|
|
13
|
+
return <ToastPrimitive.Provider {...props} />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
|
|
17
|
+
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
|
|
21
|
+
return (
|
|
22
|
+
<ToastPrimitive.Viewport
|
|
23
|
+
data-slot="toast-viewport"
|
|
24
|
+
className={cn(
|
|
25
|
+
"pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
|
|
34
|
+
return (
|
|
35
|
+
<ToastPrimitive.Root
|
|
36
|
+
data-slot="toast"
|
|
37
|
+
className={cn(
|
|
38
|
+
"group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
39
|
+
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
|
|
40
|
+
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
|
|
41
|
+
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
|
42
|
+
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
|
|
43
|
+
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
|
|
44
|
+
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
|
|
45
|
+
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
|
46
|
+
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
|
47
|
+
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
|
48
|
+
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
|
49
|
+
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
|
50
|
+
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
|
51
|
+
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
|
52
|
+
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
|
53
|
+
className
|
|
54
|
+
)}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
|
|
61
|
+
return (
|
|
62
|
+
<ToastPrimitive.Content
|
|
63
|
+
data-slot="toast-content"
|
|
64
|
+
className={cn(
|
|
65
|
+
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
|
|
66
|
+
className
|
|
67
|
+
)}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
|
|
74
|
+
return (
|
|
75
|
+
<ToastPrimitive.Title
|
|
76
|
+
data-slot="toast-title"
|
|
77
|
+
className={cn("text-sm font-medium", className)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ToastDescription({
|
|
84
|
+
className,
|
|
85
|
+
...props
|
|
86
|
+
}: ToastPrimitive.Description.Props) {
|
|
87
|
+
return (
|
|
88
|
+
<ToastPrimitive.Description
|
|
89
|
+
data-slot="toast-description"
|
|
90
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
91
|
+
{...props}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ToastAction({
|
|
97
|
+
className,
|
|
98
|
+
render = <Button variant="outline" size="sm" />,
|
|
99
|
+
...props
|
|
100
|
+
}: ToastPrimitive.Action.Props) {
|
|
101
|
+
return (
|
|
102
|
+
<ToastPrimitive.Action
|
|
103
|
+
data-slot="toast-action"
|
|
104
|
+
render={render}
|
|
105
|
+
className={cn("shrink-0", className)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ToastClose({
|
|
112
|
+
className,
|
|
113
|
+
children,
|
|
114
|
+
render = <Button variant="ghost" size="icon-sm" />,
|
|
115
|
+
...props
|
|
116
|
+
}: ToastPrimitive.Close.Props) {
|
|
117
|
+
return (
|
|
118
|
+
<ToastPrimitive.Close
|
|
119
|
+
data-slot="toast-close"
|
|
120
|
+
aria-label="Close toast"
|
|
121
|
+
render={render}
|
|
122
|
+
className={cn(
|
|
123
|
+
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
|
|
124
|
+
className
|
|
125
|
+
)}
|
|
126
|
+
{...props}
|
|
127
|
+
>
|
|
128
|
+
{children ?? (
|
|
129
|
+
<XIcon aria-hidden="true" />
|
|
130
|
+
)}
|
|
131
|
+
</ToastPrimitive.Close>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ToastIcon({ type }: { type: string | undefined }) {
|
|
136
|
+
let icon: React.ReactNode = null
|
|
137
|
+
|
|
138
|
+
if (type === "success") {
|
|
139
|
+
icon = (
|
|
140
|
+
<CircleCheckIcon aria-hidden="true" />
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (type === "info") {
|
|
145
|
+
icon = (
|
|
146
|
+
<InfoIcon aria-hidden="true" />
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (type === "warning") {
|
|
151
|
+
icon = (
|
|
152
|
+
<TriangleAlertIcon aria-hidden="true" />
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (type === "error") {
|
|
157
|
+
icon = (
|
|
158
|
+
<OctagonXIcon className="text-destructive" aria-hidden="true" />
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (type === "loading") {
|
|
163
|
+
icon = (
|
|
164
|
+
<Loader2Icon className="animate-spin" aria-hidden="true" />
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!icon) {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<span
|
|
174
|
+
data-slot="toast-icon"
|
|
175
|
+
className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
|
|
176
|
+
>
|
|
177
|
+
{icon}
|
|
178
|
+
</span>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ToastList() {
|
|
183
|
+
const { toasts } = ToastPrimitive.useToastManager()
|
|
184
|
+
|
|
185
|
+
return toasts.map((toastItem) => (
|
|
186
|
+
<Toast key={toastItem.id} toast={toastItem}>
|
|
187
|
+
<ToastContent>
|
|
188
|
+
<ToastIcon type={toastItem.type} />
|
|
189
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
190
|
+
<ToastTitle />
|
|
191
|
+
<ToastDescription />
|
|
192
|
+
</div>
|
|
193
|
+
<ToastAction />
|
|
194
|
+
<ToastClose />
|
|
195
|
+
</ToastContent>
|
|
196
|
+
</Toast>
|
|
197
|
+
))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function Toaster({
|
|
201
|
+
children,
|
|
202
|
+
toastManager = toast,
|
|
203
|
+
...props
|
|
204
|
+
}: ToastPrimitive.Provider.Props) {
|
|
205
|
+
return (
|
|
206
|
+
<ToastProvider toastManager={toastManager} {...props}>
|
|
207
|
+
{children}
|
|
208
|
+
<ToastPortal>
|
|
209
|
+
<ToastViewport>
|
|
210
|
+
<ToastList />
|
|
211
|
+
</ToastViewport>
|
|
212
|
+
</ToastPortal>
|
|
213
|
+
</ToastProvider>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const createToastManager = ToastPrimitive.createToastManager
|
|
218
|
+
const useToastManager = ToastPrimitive.useToastManager
|
|
219
|
+
|
|
220
|
+
export {
|
|
221
|
+
Toaster,
|
|
222
|
+
Toast,
|
|
223
|
+
ToastAction,
|
|
224
|
+
ToastClose,
|
|
225
|
+
ToastContent,
|
|
226
|
+
ToastDescription,
|
|
227
|
+
ToastPortal,
|
|
228
|
+
ToastProvider,
|
|
229
|
+
ToastTitle,
|
|
230
|
+
ToastViewport,
|
|
231
|
+
createToastManager,
|
|
232
|
+
toast,
|
|
233
|
+
useToastManager,
|
|
234
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// shadcn/ui tooltip (Base UI, base-nova). Framework-owned: eject with `qb reveal framework/components/ui/tooltip.tsx`.
|
|
2
|
+
|
|
3
|
+
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
|
4
|
+
import { cn } from "../../lib/utils.ts"
|
|
5
|
+
|
|
6
|
+
function TooltipProvider({
|
|
7
|
+
delay = 0,
|
|
8
|
+
...props
|
|
9
|
+
}: TooltipPrimitive.Provider.Props) {
|
|
10
|
+
return (
|
|
11
|
+
<TooltipPrimitive.Provider
|
|
12
|
+
data-slot="tooltip-provider"
|
|
13
|
+
delay={delay}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
|
20
|
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
|
24
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function TooltipContent({
|
|
28
|
+
className,
|
|
29
|
+
side = "top",
|
|
30
|
+
sideOffset = 4,
|
|
31
|
+
align = "center",
|
|
32
|
+
alignOffset = 0,
|
|
33
|
+
children,
|
|
34
|
+
...props
|
|
35
|
+
}: TooltipPrimitive.Popup.Props &
|
|
36
|
+
Pick<
|
|
37
|
+
TooltipPrimitive.Positioner.Props,
|
|
38
|
+
"align" | "alignOffset" | "side" | "sideOffset"
|
|
39
|
+
>) {
|
|
40
|
+
return (
|
|
41
|
+
<TooltipPrimitive.Portal>
|
|
42
|
+
<TooltipPrimitive.Positioner
|
|
43
|
+
align={align}
|
|
44
|
+
alignOffset={alignOffset}
|
|
45
|
+
side={side}
|
|
46
|
+
sideOffset={sideOffset}
|
|
47
|
+
className="isolate z-50"
|
|
48
|
+
>
|
|
49
|
+
<TooltipPrimitive.Popup
|
|
50
|
+
data-slot="tooltip-content"
|
|
51
|
+
className={cn(
|
|
52
|
+
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
53
|
+
className
|
|
54
|
+
)}
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
|
59
|
+
</TooltipPrimitive.Popup>
|
|
60
|
+
</TooltipPrimitive.Positioner>
|
|
61
|
+
</TooltipPrimitive.Portal>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
package/framework/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { QuestionBank } from './components/question-bank.tsx';
|
|
|
5
5
|
export { QuizPage } from './components/quiz-page.tsx';
|
|
6
6
|
export { QuizList } from './components/quiz-list.tsx';
|
|
7
7
|
export { AppShell } from './components/app-shell.tsx';
|
|
8
|
+
export { ExamTimerBadge, ExamTimerPage } from './components/exam-timer.tsx';
|
|
8
9
|
export { FilterBuilder } from './components/filter-builder.tsx';
|
|
9
10
|
export { MarkdownContent } from './components/markdown-content.tsx';
|
|
10
11
|
export { NumericalResponse, PairingResponse, formatNumericAnswer } from './components/question-response.tsx';
|
|
@@ -32,5 +33,8 @@ export {
|
|
|
32
33
|
syncQuiz,
|
|
33
34
|
} from './lib/quiz-storage.ts';
|
|
34
35
|
export { getQuestionStatuses, setQuestionStatus } from './lib/question-status-storage.ts';
|
|
36
|
+
export { examState, setExamTimer, useExamTimer } from './lib/exam-timer.ts';
|
|
37
|
+
export type { ExamState, ExamTimer } from './lib/exam-timer.ts';
|
|
38
|
+
export { Toaster, toast } from './components/ui/toast.tsx';
|
|
35
39
|
export type { StorageTarget } from './lib/sync-client.ts';
|
|
36
40
|
export { cn } from './lib/utils.ts';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Mock-exam clock: pressing Start on /timer pretends it's the exam's start time
|
|
2
|
+
// (e.g. 08:30) right now, then the clock runs forward from there. Kept in
|
|
3
|
+
// localStorage so it survives reloads and is shared by every open tab.
|
|
4
|
+
// Framework-owned: eject with `qb reveal framework/lib/exam-timer.ts`.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
7
|
+
|
|
8
|
+
export type ExamTimer = {
|
|
9
|
+
/** Exam start on the mock clock, in minutes after midnight (08:30 → 510). */
|
|
10
|
+
startMinutes: number;
|
|
11
|
+
/** When Start was pressed (epoch ms). */
|
|
12
|
+
startedAt: number;
|
|
13
|
+
readingMinutes: number;
|
|
14
|
+
writingMinutes: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ExamState = {
|
|
18
|
+
phase: 'reading' | 'writing' | 'over';
|
|
19
|
+
/** Mock clock time, "HH:MM". */
|
|
20
|
+
clock: string;
|
|
21
|
+
/** Ms since Start was pressed. */
|
|
22
|
+
elapsed: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const MINUTE = 60_000;
|
|
26
|
+
const CHANGE_EVENT = 'qb-exam-timer-change';
|
|
27
|
+
const keyFor = (namespace: string) => `${namespace}-question-bank-exam-timer`;
|
|
28
|
+
|
|
29
|
+
const isTimer = (value: unknown): value is ExamTimer => {
|
|
30
|
+
if (!value || typeof value !== 'object') return false;
|
|
31
|
+
const { startMinutes, startedAt, readingMinutes, writingMinutes } = value as Record<string, unknown>;
|
|
32
|
+
return [startMinutes, startedAt, readingMinutes, writingMinutes].every(
|
|
33
|
+
(n) => typeof n === 'number' && Number.isFinite(n) && n >= 0,
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const parse = (raw: string | null): ExamTimer | null => {
|
|
38
|
+
try {
|
|
39
|
+
const value: unknown = JSON.parse(raw ?? 'null');
|
|
40
|
+
return isTimer(value) ? value : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function setExamTimer(namespace: string, timer: ExamTimer | null): void {
|
|
47
|
+
if (typeof window === 'undefined') return;
|
|
48
|
+
if (timer) window.localStorage.setItem(keyFor(namespace), JSON.stringify(timer));
|
|
49
|
+
else window.localStorage.removeItem(keyFor(namespace));
|
|
50
|
+
// `storage` only fires in other tabs; tell this one too.
|
|
51
|
+
window.dispatchEvent(new Event(CHANGE_EVENT));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function subscribe(onChange: () => void) {
|
|
55
|
+
window.addEventListener('storage', onChange);
|
|
56
|
+
window.addEventListener(CHANGE_EVENT, onChange);
|
|
57
|
+
return () => {
|
|
58
|
+
window.removeEventListener('storage', onChange);
|
|
59
|
+
window.removeEventListener(CHANGE_EVENT, onChange);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The saved timer, or null. Always null during SSR. */
|
|
64
|
+
export function useExamTimer(namespace: string): ExamTimer | null {
|
|
65
|
+
const raw = useSyncExternalStore(
|
|
66
|
+
subscribe,
|
|
67
|
+
() => window.localStorage.getItem(keyFor(namespace)),
|
|
68
|
+
() => null,
|
|
69
|
+
);
|
|
70
|
+
return parse(raw);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const formatClock = (minutesAfterMidnight: number) => {
|
|
74
|
+
const minutes = Math.floor(minutesAfterMidnight) % (24 * 60);
|
|
75
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function examState(timer: ExamTimer, now: number): ExamState {
|
|
79
|
+
const elapsed = Math.max(0, now - timer.startedAt);
|
|
80
|
+
const minutes = elapsed / MINUTE;
|
|
81
|
+
const phase =
|
|
82
|
+
minutes < timer.readingMinutes ? 'reading' : minutes < timer.readingMinutes + timer.writingMinutes ? 'writing' : 'over';
|
|
83
|
+
return { phase, clock: formatClock(timer.startMinutes + minutes), elapsed };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Current time, re-read each time the mock clock ticks over a minute (and
|
|
88
|
+
* when the tab becomes visible again). Never re-renders more than once a minute.
|
|
89
|
+
*/
|
|
90
|
+
export function useExamNow(timer: ExamTimer | null): number {
|
|
91
|
+
const [now, setNow] = useState(() => Date.now());
|
|
92
|
+
const startedAt = timer?.startedAt;
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (startedAt === undefined) return;
|
|
95
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
96
|
+
const tick = () => {
|
|
97
|
+
const current = Date.now();
|
|
98
|
+
setNow(current);
|
|
99
|
+
const intoMinute = (((current - startedAt) % MINUTE) + MINUTE) % MINUTE;
|
|
100
|
+
timeout = setTimeout(tick, MINUTE - intoMinute + 50);
|
|
101
|
+
};
|
|
102
|
+
const onVisible = () => {
|
|
103
|
+
if (document.visibilityState !== 'visible') return;
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
tick();
|
|
106
|
+
};
|
|
107
|
+
tick();
|
|
108
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
109
|
+
return () => {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
112
|
+
};
|
|
113
|
+
}, [startedAt]);
|
|
114
|
+
return now;
|
|
115
|
+
}
|
|
@@ -1,24 +1,35 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Import this from your app stylesheet
|
|
3
|
-
* copy-pasting the tokens, so every bank inherits upstream token changes:
|
|
1
|
+
/* Design system for question-bank-framework: shadcn/ui neutral tokens + Inter.
|
|
2
|
+
* Import this from your app stylesheet after tailwindcss:
|
|
4
3
|
*
|
|
5
4
|
* @import 'tailwindcss';
|
|
6
|
-
* @source '../node_modules/question-bank-framework';
|
|
7
5
|
* @import 'question-bank-framework/framework/styles/theme.css';
|
|
8
6
|
*/
|
|
9
7
|
|
|
8
|
+
/* Generate utilities used by the framework's components (paths resolve
|
|
9
|
+
* relative to this file, so this also works when the package is linked). */
|
|
10
|
+
@source '..';
|
|
11
|
+
|
|
12
|
+
/* Inter variable with both axes (wght 100–900, opsz 14–32), upright and italic.
|
|
13
|
+
* `font-optical-sizing: auto` (below) maps opsz to the font size, so small
|
|
14
|
+
* text gets Inter Text (open spacing) and headings get Inter Display (tight
|
|
15
|
+
* spacing, finer details). Don't add negative tracking on top of it. */
|
|
16
|
+
@import '@fontsource-variable/inter/opsz.css';
|
|
17
|
+
@import '@fontsource-variable/inter/opsz-italic.css';
|
|
18
|
+
@import 'tw-animate-css';
|
|
10
19
|
/* KaTeX CSS from the framework's own katex, matching the version that renders
|
|
11
20
|
* math in MarkdownContent. Don't import katex CSS separately in your app. */
|
|
12
21
|
@import 'katex/dist/katex.min.css';
|
|
13
22
|
|
|
23
|
+
@custom-variant dark (&:is(.dark *));
|
|
24
|
+
|
|
14
25
|
@theme inline {
|
|
15
|
-
--font-sans: '
|
|
16
|
-
--font-
|
|
17
|
-
--
|
|
18
|
-
--radius-
|
|
19
|
-
--radius-md: calc(var(--radius) - 2px);
|
|
26
|
+
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
|
27
|
+
--font-mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
|
|
28
|
+
--radius-sm: calc(var(--radius) * 0.6);
|
|
29
|
+
--radius-md: calc(var(--radius) * 0.8);
|
|
20
30
|
--radius-lg: var(--radius);
|
|
21
|
-
--radius-xl: calc(var(--radius)
|
|
31
|
+
--radius-xl: calc(var(--radius) * 1.4);
|
|
32
|
+
--radius-2xl: calc(var(--radius) * 1.8);
|
|
22
33
|
--color-background: var(--background);
|
|
23
34
|
--color-foreground: var(--foreground);
|
|
24
35
|
--color-card: var(--card);
|
|
@@ -34,6 +45,8 @@
|
|
|
34
45
|
--color-accent: var(--accent);
|
|
35
46
|
--color-accent-foreground: var(--accent-foreground);
|
|
36
47
|
--color-destructive: var(--destructive);
|
|
48
|
+
--color-success: var(--success);
|
|
49
|
+
--color-warning: var(--warning);
|
|
37
50
|
--color-border: var(--border);
|
|
38
51
|
--color-input: var(--input);
|
|
39
52
|
--color-ring: var(--ring);
|
|
@@ -41,22 +54,47 @@
|
|
|
41
54
|
|
|
42
55
|
:root {
|
|
43
56
|
--radius: 0.625rem;
|
|
44
|
-
--background:
|
|
45
|
-
--foreground:
|
|
46
|
-
--card:
|
|
47
|
-
--card-foreground:
|
|
48
|
-
--popover:
|
|
49
|
-
--popover-foreground:
|
|
50
|
-
--primary:
|
|
51
|
-
--primary-foreground:
|
|
52
|
-
--secondary:
|
|
53
|
-
--secondary-foreground:
|
|
54
|
-
--muted:
|
|
55
|
-
--muted-foreground:
|
|
56
|
-
--accent:
|
|
57
|
-
--accent-foreground:
|
|
58
|
-
--destructive:
|
|
59
|
-
--
|
|
60
|
-
--
|
|
61
|
-
--
|
|
57
|
+
--background: oklch(1 0 0);
|
|
58
|
+
--foreground: oklch(0.145 0 0);
|
|
59
|
+
--card: oklch(1 0 0);
|
|
60
|
+
--card-foreground: oklch(0.145 0 0);
|
|
61
|
+
--popover: oklch(1 0 0);
|
|
62
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
63
|
+
--primary: oklch(0.205 0 0);
|
|
64
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
65
|
+
--secondary: oklch(0.97 0 0);
|
|
66
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
67
|
+
--muted: oklch(0.97 0 0);
|
|
68
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
69
|
+
--accent: oklch(0.97 0 0);
|
|
70
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
71
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
72
|
+
--success: oklch(0.527 0.154 150.069);
|
|
73
|
+
--warning: oklch(0.666 0.179 58.318);
|
|
74
|
+
--border: oklch(0.922 0 0);
|
|
75
|
+
--input: oklch(0.922 0 0);
|
|
76
|
+
--ring: oklch(0.708 0 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@layer base {
|
|
80
|
+
* {
|
|
81
|
+
@apply border-border outline-ring/50;
|
|
82
|
+
}
|
|
83
|
+
html {
|
|
84
|
+
font-optical-sizing: auto;
|
|
85
|
+
-webkit-font-smoothing: antialiased;
|
|
86
|
+
-moz-osx-font-smoothing: grayscale;
|
|
87
|
+
text-rendering: optimizeLegibility;
|
|
88
|
+
}
|
|
89
|
+
body {
|
|
90
|
+
@apply bg-background font-sans text-foreground;
|
|
91
|
+
}
|
|
92
|
+
h1,
|
|
93
|
+
h2,
|
|
94
|
+
h3 {
|
|
95
|
+
text-wrap: balance;
|
|
96
|
+
}
|
|
97
|
+
p {
|
|
98
|
+
text-wrap: pretty;
|
|
99
|
+
}
|
|
62
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "question-bank-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"react-dom": "^19.2.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@base-ui/react": "^1.9.0",
|
|
36
|
+
"@fontsource-variable/inter": "^5.3.0",
|
|
35
37
|
"@tanstack/react-table": "^8.21.3",
|
|
36
38
|
"@tanstack/react-virtual": "^3.14.19",
|
|
37
39
|
"class-variance-authority": "^0.7.1",
|
|
@@ -43,6 +45,7 @@
|
|
|
43
45
|
"remark-gfm": "^4.0.1",
|
|
44
46
|
"remark-math": "^6.0.0",
|
|
45
47
|
"tailwind-merge": "^3.7.0",
|
|
48
|
+
"tw-animate-css": "^1.4.0",
|
|
46
49
|
"zustand": "^5.0.15"
|
|
47
50
|
},
|
|
48
51
|
"devDependencies": {
|
|
@@ -35,7 +35,7 @@ function RootDocument({ children }: { children: ReactNode }) {
|
|
|
35
35
|
<head>
|
|
36
36
|
<HeadContent />
|
|
37
37
|
</head>
|
|
38
|
-
<body className="
|
|
38
|
+
<body className="min-h-dvh">
|
|
39
39
|
{children}
|
|
40
40
|
<Scripts />
|
|
41
41
|
</body>
|
|
@@ -1,6 +1,2 @@
|
|
|
1
|
-
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap');
|
|
2
1
|
@import 'tailwindcss';
|
|
3
|
-
@import 'tw-animate-css';
|
|
4
2
|
@import 'question-bank-framework/framework/styles/theme.css';
|
|
5
|
-
|
|
6
|
-
@source '../../node_modules/question-bank-framework';
|