question-bank-framework 0.7.1 → 0.8.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 +11 -0
- package/framework/components/app-shell.tsx +36 -26
- package/framework/components/exam-timer.tsx +125 -0
- package/framework/components/ui/toast.tsx +234 -0
- package/framework/index.ts +4 -0
- package/framework/lib/exam-timer.ts +115 -0
- package/package.json +2 -1
- package/template/src/routes/timer.tsx +4 -0
package/README.md
CHANGED
|
@@ -48,6 +48,17 @@ Pass `hideTagsColumn: true` to `defineBank` when tags merely duplicate
|
|
|
48
48
|
dedicated fields (every tag equals a topic/type value already in its own
|
|
49
49
|
column). Tags still feed free-text search.
|
|
50
50
|
|
|
51
|
+
## Mock-exam timer
|
|
52
|
+
|
|
53
|
+
The header has a **Timer** link to `/timer` (add `src/routes/timer.tsx`
|
|
54
|
+
rendering `<ExamTimerPage />`, as in the template). Enter the exam's start
|
|
55
|
+
time (e.g. 08:30) and reading/writing minutes, then press Start: the top right
|
|
56
|
+
of the header shows the mock exam time, starting at 08:30, with a book icon
|
|
57
|
+
during reading time and a pen during writing time. It updates once a minute.
|
|
58
|
+
Toasts announce reading time over, 30 and 10 minutes remaining, and exam over;
|
|
59
|
+
the clock disappears once the exam is over. The timer lives in localStorage,
|
|
60
|
+
so it survives reloads and shows in every open tab.
|
|
61
|
+
|
|
51
62
|
## Sync (Cloudflare Durable Objects)
|
|
52
63
|
|
|
53
64
|
Without `sync`, quizzes and question statuses live only in the browser's
|
|
@@ -4,51 +4,61 @@
|
|
|
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-white', isQuestionBank ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
|
|
34
|
+
<header className="sticky top-0 z-20 shrink-0 border-b border-gray-200 bg-white">
|
|
35
|
+
<div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-8 px-4">
|
|
36
|
+
<Link to="/" className="text-lg font-bold text-gray-900">
|
|
37
|
+
{brand}
|
|
38
|
+
</Link>
|
|
39
|
+
<nav className="flex items-center gap-6" 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
|
+
className={cn('text-sm font-semibold text-gray-600 hover:text-gray-900', active && 'text-gray-900 underline underline-offset-4')}
|
|
47
|
+
>
|
|
48
|
+
{label}
|
|
49
|
+
</Link>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</nav>
|
|
53
|
+
<div className="ml-auto">
|
|
54
|
+
<ExamTimerBadge />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</header>
|
|
58
|
+
<main className={cn('w-full flex-1', isQuestionBank ? 'flex min-h-0 flex-col overflow-hidden' : 'mx-auto max-w-6xl px-4 py-8')}>
|
|
59
|
+
{children}
|
|
60
|
+
</main>
|
|
61
|
+
</div>
|
|
62
|
+
</Toaster>
|
|
53
63
|
);
|
|
54
64
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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 { Input } from './ui/input';
|
|
10
|
+
import { toast } from './ui/toast';
|
|
11
|
+
import { examState, formatClock, MINUTE, setExamTimer, useExamNow, useExamTimer, type ExamTimer } from '../lib/exam-timer.ts';
|
|
12
|
+
|
|
13
|
+
/** Moments to announce, in minutes after Start. */
|
|
14
|
+
const milestones = ({ readingMinutes: reading, writingMinutes: writing }: ExamTimer) => {
|
|
15
|
+
const end = reading + writing;
|
|
16
|
+
return [
|
|
17
|
+
...(reading > 0 ? [{ at: reading, title: 'Reading time over' }] : []),
|
|
18
|
+
...(writing > 30 ? [{ at: end - 30, title: '30 minutes remaining' }] : []),
|
|
19
|
+
...(writing > 10 ? [{ at: end - 10, title: '10 minutes remaining' }] : []),
|
|
20
|
+
{ at: end, title: 'Exam over' },
|
|
21
|
+
];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function ExamTimerBadge() {
|
|
25
|
+
const { namespace } = useBank();
|
|
26
|
+
const timer = useExamTimer(namespace);
|
|
27
|
+
const now = useExamNow(timer);
|
|
28
|
+
const state = timer ? examState(timer, now) : null;
|
|
29
|
+
|
|
30
|
+
// Toast each milestone crossed since the last tick (not ones already past on load).
|
|
31
|
+
const last = useRef<{ startedAt: number; elapsed: number } | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!timer || !state) return;
|
|
34
|
+
const previous = last.current;
|
|
35
|
+
if (previous?.startedAt === timer.startedAt) {
|
|
36
|
+
for (const { at, title } of milestones(timer)) {
|
|
37
|
+
if (previous.elapsed < at * MINUTE && at * MINUTE <= state.elapsed) toast.add({ title });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
last.current = { startedAt: timer.startedAt, elapsed: state.elapsed };
|
|
41
|
+
}, [timer?.startedAt, state?.elapsed]);
|
|
42
|
+
|
|
43
|
+
if (!state || state.phase === 'over') return null;
|
|
44
|
+
const Icon = state.phase === 'reading' ? BookOpen : PenLine;
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className="flex items-center gap-1.5 text-sm font-medium tabular-nums text-gray-700"
|
|
48
|
+
title={state.phase === 'reading' ? 'Reading time' : 'Writing time'}
|
|
49
|
+
>
|
|
50
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
51
|
+
<span className="sr-only">{state.phase === 'reading' ? 'Reading time' : 'Writing time'}, mock exam time </span>
|
|
52
|
+
{state.clock}
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parseClock = (time: string) => {
|
|
58
|
+
const [hours, minutes] = time.split(':').map(Number);
|
|
59
|
+
return (hours ?? 0) * 60 + (minutes ?? 0);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function ExamTimerPage({
|
|
63
|
+
defaultStart = '08:30',
|
|
64
|
+
defaultReadingMinutes = 15,
|
|
65
|
+
defaultWritingMinutes = 180,
|
|
66
|
+
}: {
|
|
67
|
+
defaultStart?: string;
|
|
68
|
+
defaultReadingMinutes?: number;
|
|
69
|
+
defaultWritingMinutes?: number;
|
|
70
|
+
}) {
|
|
71
|
+
const { namespace } = useBank();
|
|
72
|
+
const timer = useExamTimer(namespace);
|
|
73
|
+
const now = useExamNow(timer);
|
|
74
|
+
const running = timer !== null && examState(timer, now).phase !== 'over';
|
|
75
|
+
const [start, setStart] = useState(defaultStart);
|
|
76
|
+
const [reading, setReading] = useState(String(defaultReadingMinutes));
|
|
77
|
+
const [writing, setWriting] = useState(String(defaultWritingMinutes));
|
|
78
|
+
|
|
79
|
+
// Show the saved settings once they're read (after hydration, or when another tab changes them).
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!timer) return;
|
|
82
|
+
setStart(formatClock(timer.startMinutes));
|
|
83
|
+
setReading(String(timer.readingMinutes));
|
|
84
|
+
setWriting(String(timer.writingMinutes));
|
|
85
|
+
}, [timer?.startedAt]);
|
|
86
|
+
|
|
87
|
+
const submit = (event: FormEvent) => {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setExamTimer(namespace, {
|
|
90
|
+
startMinutes: parseClock(start),
|
|
91
|
+
startedAt: Date.now(),
|
|
92
|
+
readingMinutes: Math.max(0, Number(reading) || 0),
|
|
93
|
+
writingMinutes: Math.max(0, Number(writing) || 0),
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const field = 'grid gap-1.5 text-sm font-medium text-gray-700';
|
|
98
|
+
return (
|
|
99
|
+
<div className="grid max-w-sm gap-6">
|
|
100
|
+
<h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">Timer</h1>
|
|
101
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
102
|
+
<label className={field}>
|
|
103
|
+
Exam start time
|
|
104
|
+
<Input type="time" required value={start} onChange={(event) => setStart(event.target.value)} />
|
|
105
|
+
</label>
|
|
106
|
+
<label className={field}>
|
|
107
|
+
Reading minutes
|
|
108
|
+
<Input type="number" min={0} required value={reading} onChange={(event) => setReading(event.target.value)} />
|
|
109
|
+
</label>
|
|
110
|
+
<label className={field}>
|
|
111
|
+
Writing minutes
|
|
112
|
+
<Input type="number" min={0} required value={writing} onChange={(event) => setWriting(event.target.value)} />
|
|
113
|
+
</label>
|
|
114
|
+
<div className="flex gap-2">
|
|
115
|
+
<Button type="submit">{running ? 'Restart' : 'Start'}</Button>
|
|
116
|
+
{running && (
|
|
117
|
+
<Button type="button" variant="outline" onClick={() => setExamTimer(namespace, null)}>
|
|
118
|
+
Stop
|
|
119
|
+
</Button>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</form>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// shadcn/ui Toast (Base UI, base-nova style). 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 border-gray-200 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
|
+
}
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "question-bank-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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,7 @@
|
|
|
32
32
|
"react-dom": "^19.2.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@base-ui/react": "^1.9.0",
|
|
35
36
|
"@tanstack/react-table": "^8.21.3",
|
|
36
37
|
"@tanstack/react-virtual": "^3.14.19",
|
|
37
38
|
"class-variance-authority": "^0.7.1",
|