question-bank-framework 0.6.0 → 0.7.1
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 +46 -5
- package/bin/qb.js +2 -1
- package/bin/qb.ts +2 -1
- package/core/types.ts +27 -7
- package/framework/cloudflare/index.ts +240 -0
- package/framework/components/markdown-content.tsx +60 -23
- package/framework/components/question-bank.tsx +27 -7
- package/framework/components/question-response.tsx +6 -4
- package/framework/components/quiz-list.tsx +4 -4
- package/framework/components/quiz-page.tsx +18 -17
- package/framework/index.ts +11 -2
- package/framework/lib/question-status-storage.ts +53 -79
- package/framework/lib/quiz-storage.ts +112 -102
- package/framework/lib/sync-client.ts +45 -0
- package/framework/styles/theme.css +4 -0
- package/package.json +2 -3
- package/template/bank.config.ts +2 -0
- package/template/src/routes/styles.css +0 -1
- package/template/src/server.ts +17 -0
- package/template/vite.config.ts +10 -0
- package/template/wrangler.jsonc +16 -0
- package/framework/convex/questionStatuses.ts +0 -30
- package/framework/convex/quizzes.ts +0 -62
- package/framework/convex/schema.ts +0 -18
- package/framework/lib/convex-api.ts +0 -43
|
@@ -1,140 +1,150 @@
|
|
|
1
|
-
// Quiz persistence
|
|
2
|
-
//
|
|
1
|
+
// Quiz persistence. localStorage is always the working copy; when the bank
|
|
2
|
+
// sets `sync`, every change is also pushed to the server and the two are
|
|
3
|
+
// reconciled (newest `updatedAt` wins, deletions are tombstoned server-side).
|
|
3
4
|
// Framework-owned: eject with `qb reveal framework/lib/quiz-storage.ts`.
|
|
4
5
|
|
|
5
|
-
import { ConvexHttpClient } from 'convex/browser';
|
|
6
|
-
import { convexApi } from './convex-api.ts';
|
|
7
6
|
import type { QuizSession } from '../../core/types.ts';
|
|
8
|
-
|
|
9
|
-
const convexUrl = import.meta.env?.VITE_CONVEX_URL || (import.meta.env?.CONVEX_URL as string | undefined);
|
|
10
|
-
const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null;
|
|
11
|
-
|
|
12
|
-
type QuizDocument = QuizSession & { _id: string; _creationTime: number };
|
|
7
|
+
import { readLocal, syncEnabled, syncRequest, writeLocal, type StorageTarget } from './sync-client.ts';
|
|
13
8
|
|
|
14
9
|
const keyFor = (namespace: string) => `${namespace}-question-bank-quizzes`;
|
|
10
|
+
const pendingDeletesKeyFor = (namespace: string) => `${namespace}-question-bank-quiz-deletes`;
|
|
15
11
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const value: unknown = JSON.parse(window.localStorage.getItem(keyFor(namespace)) ?? '[]');
|
|
20
|
-
return Array.isArray(value) ? (value as QuizSession[]) : [];
|
|
21
|
-
} catch {
|
|
22
|
-
return [];
|
|
23
|
-
}
|
|
24
|
-
};
|
|
12
|
+
const isQuizList = (value: unknown): value is QuizSession[] => Array.isArray(value);
|
|
13
|
+
const isStringList = (value: unknown): value is string[] =>
|
|
14
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
25
15
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
16
|
+
const readLocalQuizzes = (namespace: string): QuizSession[] => readLocal(keyFor(namespace), [], isQuizList);
|
|
17
|
+
const writeLocalQuizzes = (namespace: string, quizzes: QuizSession[]) => writeLocal(keyFor(namespace), sortQuizzes(quizzes));
|
|
18
|
+
const readPendingDeletes = (namespace: string): string[] => readLocal(pendingDeletesKeyFor(namespace), [], isStringList);
|
|
19
|
+
const writePendingDeletes = (namespace: string, ids: string[]) => writeLocal(pendingDeletesKeyFor(namespace), ids);
|
|
29
20
|
|
|
30
|
-
|
|
21
|
+
const versionOf = (quiz: QuizSession): number => quiz.updatedAt ?? quiz.createdAt;
|
|
22
|
+
const sortQuizzes = (quizzes: QuizSession[]) => [...quizzes].sort((a, b) => b.createdAt - a.createdAt);
|
|
23
|
+
|
|
24
|
+
function upsertLocal(namespace: string, session: QuizSession): void {
|
|
31
25
|
writeLocalQuizzes(namespace, [session, ...readLocalQuizzes(namespace).filter((quiz) => quiz.id !== session.id)]);
|
|
32
26
|
}
|
|
33
27
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
function removeLocal(namespace: string, id: string): void {
|
|
29
|
+
writeLocalQuizzes(namespace, readLocalQuizzes(namespace).filter((quiz) => quiz.id !== id));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Push one quiz. The server keeps the newer copy and returns what it stored (410 when deleted). */
|
|
33
|
+
async function pushQuiz(target: StorageTarget, session: QuizSession): Promise<void> {
|
|
34
|
+
if (!syncEnabled(target)) return;
|
|
35
|
+
const { status, data } = await syncRequest<QuizSession>(target, `/quizzes/${encodeURIComponent(session.id)}`, {
|
|
36
|
+
method: 'PUT',
|
|
37
|
+
body: { ...session, updatedAt: versionOf(session) },
|
|
38
|
+
});
|
|
39
|
+
if (status === 410) {
|
|
40
|
+
removeLocal(target.namespace, session.id);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const local = readLocalQuizzes(target.namespace).find((quiz) => quiz.id === session.id);
|
|
44
|
+
if (!local || versionOf(data) > versionOf(local)) upsertLocal(target.namespace, data);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Two-way merge: retry pending deletes, adopt newer server copies, drop
|
|
49
|
+
* server-deleted quizzes, and upload local quizzes the server lacks or has
|
|
50
|
+
* an older copy of. Falls back to the local list when the server is unreachable.
|
|
51
|
+
*/
|
|
52
|
+
async function reconcile(target: StorageTarget): Promise<QuizSession[]> {
|
|
53
|
+
const namespace = target.namespace;
|
|
54
|
+
if (!syncEnabled(target)) return sortQuizzes(readLocalQuizzes(namespace));
|
|
55
|
+
try {
|
|
56
|
+
const pending = readPendingDeletes(namespace);
|
|
57
|
+
for (const id of pending) {
|
|
58
|
+
await syncRequest(target, `/quizzes/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
59
|
+
}
|
|
60
|
+
if (pending.length > 0) writePendingDeletes(namespace, []);
|
|
61
|
+
|
|
62
|
+
const { data } = await syncRequest<{ quizzes: QuizSession[]; deleted: string[] }>(target, '/quizzes');
|
|
63
|
+
const deleted = new Set(data.deleted);
|
|
64
|
+
const remote = new Map(data.quizzes.map((quiz) => [quiz.id, quiz]));
|
|
65
|
+
const merged = new Map<string, QuizSession>(remote);
|
|
66
|
+
const uploads: QuizSession[] = [];
|
|
67
|
+
for (const local of readLocalQuizzes(namespace)) {
|
|
68
|
+
if (deleted.has(local.id)) continue;
|
|
69
|
+
const server = remote.get(local.id);
|
|
70
|
+
if (!server || versionOf(local) > versionOf(server)) {
|
|
71
|
+
merged.set(local.id, local);
|
|
72
|
+
uploads.push(local);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
writeLocalQuizzes(namespace, [...merged.values()]);
|
|
76
|
+
await Promise.all(uploads.map((quiz) => pushQuiz(target, quiz)));
|
|
77
|
+
return sortQuizzes(readLocalQuizzes(namespace));
|
|
78
|
+
} catch {
|
|
79
|
+
return sortQuizzes(readLocalQuizzes(namespace));
|
|
37
80
|
}
|
|
38
|
-
writeLocalQuizzes(
|
|
39
|
-
namespace,
|
|
40
|
-
readLocalQuizzes(namespace).filter((quiz) => quiz.id !== id),
|
|
41
|
-
);
|
|
42
81
|
}
|
|
43
82
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
answers: document.answers,
|
|
49
|
-
currentQuestion: document.currentQuestion,
|
|
50
|
-
completed: document.completed,
|
|
51
|
-
createdAt: document.createdAt ?? document._creationTime,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
export async function createQuiz(namespace: string, title: string, questionIds: string[]): Promise<QuizSession> {
|
|
55
|
-
const localSession: QuizSession = {
|
|
56
|
-
id: `local_${crypto.randomUUID()}`,
|
|
83
|
+
export async function createQuiz(target: StorageTarget, title: string, questionIds: string[]): Promise<QuizSession> {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const session: QuizSession = {
|
|
86
|
+
id: crypto.randomUUID(),
|
|
57
87
|
title,
|
|
58
88
|
questionIds,
|
|
59
89
|
answers: {},
|
|
60
90
|
currentQuestion: 0,
|
|
61
91
|
completed: false,
|
|
62
|
-
createdAt:
|
|
92
|
+
createdAt: now,
|
|
93
|
+
updatedAt: now,
|
|
63
94
|
};
|
|
95
|
+
upsertLocal(target.namespace, session);
|
|
96
|
+
await pushQuiz(target, session).catch(() => {
|
|
97
|
+
// Stays local; the next reconcile uploads it.
|
|
98
|
+
});
|
|
99
|
+
return session;
|
|
100
|
+
}
|
|
64
101
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
export async function getQuiz(target: StorageTarget, id: string): Promise<QuizSession | null> {
|
|
103
|
+
const quizzes = await reconcile(target);
|
|
104
|
+
return quizzes.find((quiz) => quiz.id === id) ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function listQuizzes(target: StorageTarget): Promise<QuizSession[]> {
|
|
108
|
+
return reconcile(target);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Record a change locally (stamps `updatedAt`). Call `syncQuiz` afterwards to push it. */
|
|
112
|
+
export function saveQuizLocally(target: StorageTarget, session: QuizSession): QuizSession {
|
|
113
|
+
const updated = { ...session, updatedAt: Math.max(Date.now(), versionOf(session) + 1) };
|
|
114
|
+
upsertLocal(target.namespace, updated);
|
|
115
|
+
return updated;
|
|
116
|
+
}
|
|
74
117
|
|
|
75
|
-
|
|
76
|
-
|
|
118
|
+
/** Push a locally saved quiz. A failed push stays local; the next reconcile uploads it. */
|
|
119
|
+
export async function syncQuiz(target: StorageTarget, session: QuizSession): Promise<void> {
|
|
120
|
+
await pushQuiz(target, session).catch(() => {});
|
|
77
121
|
}
|
|
78
122
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
123
|
+
/** saveQuizLocally + syncQuiz. */
|
|
124
|
+
export async function saveQuiz(target: StorageTarget, session: QuizSession): Promise<QuizSession> {
|
|
125
|
+
const updated = saveQuizLocally(target, session);
|
|
126
|
+
await syncQuiz(target, updated);
|
|
127
|
+
return updated;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function deleteQuiz(target: StorageTarget, id: string): Promise<void> {
|
|
131
|
+
removeLocal(target.namespace, id);
|
|
132
|
+
if (!syncEnabled(target)) return;
|
|
82
133
|
try {
|
|
83
|
-
|
|
84
|
-
return document ? fromDocument(document) : (localSession ?? null);
|
|
134
|
+
await syncRequest(target, `/quizzes/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
85
135
|
} catch {
|
|
86
|
-
|
|
136
|
+
writePendingDeletes(target.namespace, [...new Set([...readPendingDeletes(target.namespace), id])]);
|
|
87
137
|
}
|
|
88
138
|
}
|
|
89
139
|
|
|
90
|
-
export async function saveQuizProgress(
|
|
91
|
-
namespace: string,
|
|
92
|
-
session: QuizSession,
|
|
93
|
-
progress: Pick<QuizSession, 'title' | 'answers' | 'currentQuestion' | 'completed'>,
|
|
94
|
-
): Promise<void> {
|
|
95
|
-
const updated = { ...session, ...progress };
|
|
96
|
-
saveQuizLocally(namespace, updated);
|
|
97
|
-
if (!convexClient || updated.id.startsWith('local_')) return;
|
|
98
|
-
await convexClient.mutation(convexApi.quizzes.saveProgress, {
|
|
99
|
-
quizId: updated.id,
|
|
100
|
-
title: updated.title,
|
|
101
|
-
answers: updated.answers,
|
|
102
|
-
currentQuestion: updated.currentQuestion,
|
|
103
|
-
completed: updated.completed,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
140
|
// Replaces a quiz's question list (reorder / remove / append). Answers for
|
|
108
141
|
// removed questions are dropped and the current position is kept on the same
|
|
109
142
|
// question where possible.
|
|
110
|
-
export async function setQuizQuestions(
|
|
143
|
+
export async function setQuizQuestions(target: StorageTarget, session: QuizSession, questionIds: string[]): Promise<QuizSession> {
|
|
111
144
|
const kept = new Set(questionIds);
|
|
112
145
|
const answers = Object.fromEntries(Object.entries(session.answers).filter(([id]) => kept.has(id)));
|
|
113
146
|
const currentId = session.questionIds[session.currentQuestion];
|
|
114
147
|
const currentIndex = currentId === undefined ? -1 : questionIds.indexOf(currentId);
|
|
115
148
|
const currentQuestion = currentIndex >= 0 ? currentIndex : Math.min(session.currentQuestion, Math.max(questionIds.length - 1, 0));
|
|
116
|
-
|
|
117
|
-
if (convexClient && !updated.id.startsWith('local_')) {
|
|
118
|
-
await convexClient.mutation(convexApi.quizzes.setQuestions, {
|
|
119
|
-
quizId: updated.id,
|
|
120
|
-
questionIds,
|
|
121
|
-
answers,
|
|
122
|
-
currentQuestion,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
saveQuizLocally(namespace, updated);
|
|
126
|
-
return updated;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export async function listQuizzes(namespace: string): Promise<QuizSession[]> {
|
|
130
|
-
const localQuizzes = readLocalQuizzes(namespace);
|
|
131
|
-
if (!convexClient) return localQuizzes;
|
|
132
|
-
try {
|
|
133
|
-
const documents = await convexClient.query(convexApi.quizzes.list, {});
|
|
134
|
-
const remoteQuizzes = documents.map(fromDocument);
|
|
135
|
-
const ids = new Set(remoteQuizzes.map((quiz) => quiz.id));
|
|
136
|
-
return [...remoteQuizzes, ...localQuizzes.filter((quiz) => !ids.has(quiz.id))];
|
|
137
|
-
} catch {
|
|
138
|
-
return localQuizzes;
|
|
139
|
-
}
|
|
149
|
+
return saveQuiz(target, { ...session, questionIds, answers, currentQuestion });
|
|
140
150
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// HTTP client for the framework's sync API (see framework/cloudflare/).
|
|
2
|
+
// Framework-owned: eject with `qb reveal framework/lib/sync-client.ts`.
|
|
3
|
+
|
|
4
|
+
import type { Bank } from '../../core/types.ts';
|
|
5
|
+
|
|
6
|
+
/** What the storage modules need from a bank: its namespace and optional sync endpoint. */
|
|
7
|
+
export type StorageTarget = Pick<Bank, 'namespace' | 'sync'>;
|
|
8
|
+
|
|
9
|
+
export function syncEnabled(target: StorageTarget): boolean {
|
|
10
|
+
return typeof window !== 'undefined' && !!target.sync?.url;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** JSON request against `<sync.url>/<namespace><path>`. Throws on network or HTTP errors. */
|
|
14
|
+
export async function syncRequest<T>(
|
|
15
|
+
target: StorageTarget,
|
|
16
|
+
path: string,
|
|
17
|
+
init?: { method?: 'GET' | 'PUT' | 'DELETE'; body?: unknown },
|
|
18
|
+
): Promise<{ status: number; data: T }> {
|
|
19
|
+
if (!target.sync?.url) throw new Error('Sync is not configured for this bank.');
|
|
20
|
+
const base = target.sync.url.replace(/\/+$/, '');
|
|
21
|
+
const response = await fetch(`${base}/${encodeURIComponent(target.namespace)}${path}`, {
|
|
22
|
+
method: init?.method ?? 'GET',
|
|
23
|
+
headers: init?.body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
24
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
|
25
|
+
credentials: 'same-origin',
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok && response.status !== 410) {
|
|
28
|
+
throw new Error(`Sync request failed: ${response.status} ${response.statusText}`);
|
|
29
|
+
}
|
|
30
|
+
return { status: response.status, data: (await response.json()) as T };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readLocal<T>(key: string, fallback: T, valid: (value: unknown) => value is T): T {
|
|
34
|
+
if (typeof window === 'undefined') return fallback;
|
|
35
|
+
try {
|
|
36
|
+
const value: unknown = JSON.parse(window.localStorage.getItem(key) ?? 'null');
|
|
37
|
+
return valid(value) ? value : fallback;
|
|
38
|
+
} catch {
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeLocal(key: string, value: unknown): void {
|
|
44
|
+
if (typeof window !== 'undefined') window.localStorage.setItem(key, JSON.stringify(value));
|
|
45
|
+
}
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* @import 'question-bank-framework/framework/styles/theme.css';
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/* KaTeX CSS from the framework's own katex, matching the version that renders
|
|
11
|
+
* math in MarkdownContent. Don't import katex CSS separately in your app. */
|
|
12
|
+
@import 'katex/dist/katex.min.css';
|
|
13
|
+
|
|
10
14
|
@theme inline {
|
|
11
15
|
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
|
|
12
16
|
--font-display: 'Fraunces', Georgia, serif;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "question-bank-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./core": "./core/index.ts",
|
|
14
14
|
"./framework": "./framework/index.ts",
|
|
15
15
|
"./framework/*": "./framework/*",
|
|
16
|
+
"./cloudflare": "./framework/cloudflare/index.ts",
|
|
16
17
|
"./template/*": "./template/*"
|
|
17
18
|
},
|
|
18
19
|
"bin": {
|
|
@@ -35,12 +36,10 @@
|
|
|
35
36
|
"@tanstack/react-virtual": "^3.14.19",
|
|
36
37
|
"class-variance-authority": "^0.7.1",
|
|
37
38
|
"clsx": "^2.1.1",
|
|
38
|
-
"convex": "^1.46.0",
|
|
39
39
|
"katex": "^0.19.0",
|
|
40
40
|
"lucide-react": "^1.57.0",
|
|
41
41
|
"radix-ui": "^1.8.1",
|
|
42
42
|
"react-markdown": "^10.1.0",
|
|
43
|
-
"rehype-katex": "^7.0.1",
|
|
44
43
|
"remark-gfm": "^4.0.1",
|
|
45
44
|
"remark-math": "^6.0.0",
|
|
46
45
|
"tailwind-merge": "^3.7.0",
|
package/template/bank.config.ts
CHANGED
|
@@ -25,6 +25,8 @@ export const bank = defineBank({
|
|
|
25
25
|
description: 'A searchable, section-classified question bank built from COMP20008 past exams.',
|
|
26
26
|
},
|
|
27
27
|
namespace: 'comp20008',
|
|
28
|
+
// Sync quizzes + statuses to the Durable Object served by src/server.ts.
|
|
29
|
+
sync: { url: '/api/qb' },
|
|
28
30
|
fields: [
|
|
29
31
|
{
|
|
30
32
|
id: 'year',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
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
|
-
@import 'katex/dist/katex.min.css';
|
|
3
2
|
@import 'tailwindcss';
|
|
4
3
|
@import 'tw-animate-css';
|
|
5
4
|
@import 'question-bank-framework/framework/styles/theme.css';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Worker entry: serves the question bank sync API under /api/qb (matching
|
|
2
|
+
// `sync: { url: '/api/qb' }` in bank.config.ts) and everything else through
|
|
3
|
+
// TanStack Start.
|
|
4
|
+
//
|
|
5
|
+
// Before deploying publicly, gate the API: pass `authorize` (e.g. verify a
|
|
6
|
+
// Cloudflare Access JWT) or put the whole Worker behind Cloudflare Access.
|
|
7
|
+
|
|
8
|
+
import handler from '@tanstack/react-start/server-entry';
|
|
9
|
+
import { handleQuestionBankRequest } from 'question-bank-framework/cloudflare';
|
|
10
|
+
|
|
11
|
+
export { QuestionBankStore } from 'question-bank-framework/cloudflare';
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
async fetch(request: Request, env: object) {
|
|
15
|
+
return (await handleQuestionBankRequest(request, env)) ?? handler.fetch(request);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { cloudflare } from '@cloudflare/vite-plugin';
|
|
3
|
+
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
|
|
4
|
+
import viteReact from '@vitejs/plugin-react';
|
|
5
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
resolve: { tsconfigPaths: true },
|
|
9
|
+
plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tailwindcss(), tanstackStart(), viteReact()],
|
|
10
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Cloudflare Workers config. `pnpm dev` runs this locally in workerd (via
|
|
2
|
+
// @cloudflare/vite-plugin) and persists the sync store to .wrangler/state;
|
|
3
|
+
// `pnpm build && wrangler deploy` publishes it. Rename "name" for your bank.
|
|
4
|
+
{
|
|
5
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
6
|
+
"name": "question-bank",
|
|
7
|
+
"compatibility_date": "2026-11-14",
|
|
8
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
9
|
+
"main": "src/server.ts",
|
|
10
|
+
"observability": { "enabled": true },
|
|
11
|
+
// Quizzes + question statuses: one SQLite-backed Durable Object per bank namespace.
|
|
12
|
+
"durable_objects": {
|
|
13
|
+
"bindings": [{ "name": "QB_STORE", "class_name": "QuestionBankStore" }]
|
|
14
|
+
},
|
|
15
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["QuestionBankStore"] }]
|
|
16
|
+
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { mutationGeneric as mutation, queryGeneric as query } from 'convex/server'
|
|
2
|
-
import { v } from 'convex/values'
|
|
3
|
-
|
|
4
|
-
const statusValidator = v.union(v.literal('unanswered'), v.literal('revisit'), v.literal('complete'))
|
|
5
|
-
|
|
6
|
-
export const list = query({
|
|
7
|
-
args: {},
|
|
8
|
-
handler: async (ctx) => await ctx.db.query('questionStatuses').collect(),
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
export const set = mutation({
|
|
12
|
-
args: {
|
|
13
|
-
questionId: v.string(),
|
|
14
|
-
status: statusValidator,
|
|
15
|
-
},
|
|
16
|
-
handler: async (ctx, { questionId, status }) => {
|
|
17
|
-
const existing = await ctx.db.query('questionStatuses')
|
|
18
|
-
.withIndex('by_question_id', (q) => q.eq('questionId', questionId))
|
|
19
|
-
.first()
|
|
20
|
-
const updatedAt = Date.now()
|
|
21
|
-
|
|
22
|
-
if (existing) {
|
|
23
|
-
await ctx.db.patch(existing._id, { status, updatedAt })
|
|
24
|
-
} else {
|
|
25
|
-
await ctx.db.insert('questionStatuses', { questionId, status, updatedAt })
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return updatedAt
|
|
29
|
-
},
|
|
30
|
-
})
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { mutationGeneric as mutation, queryGeneric as query } from 'convex/server'
|
|
2
|
-
import { v } from 'convex/values'
|
|
3
|
-
|
|
4
|
-
export const create = mutation({
|
|
5
|
-
args: {
|
|
6
|
-
title: v.string(),
|
|
7
|
-
questionIds: v.array(v.string()),
|
|
8
|
-
},
|
|
9
|
-
handler: async (ctx, { title, questionIds }) => {
|
|
10
|
-
const createdAt = Date.now()
|
|
11
|
-
return await ctx.db.insert('quizzes', {
|
|
12
|
-
title,
|
|
13
|
-
questionIds,
|
|
14
|
-
answers: {},
|
|
15
|
-
currentQuestion: 0,
|
|
16
|
-
completed: false,
|
|
17
|
-
createdAt,
|
|
18
|
-
})
|
|
19
|
-
},
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
export const get = query({
|
|
23
|
-
args: { quizId: v.id('quizzes') },
|
|
24
|
-
handler: async (ctx, { quizId }) => await ctx.db.get(quizId),
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
export const list = query({
|
|
28
|
-
args: {},
|
|
29
|
-
handler: async (ctx) => await ctx.db.query('quizzes').withIndex('by_created_at').order('desc').collect(),
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
export const remove = mutation({
|
|
33
|
-
args: { quizId: v.id('quizzes') },
|
|
34
|
-
handler: async (ctx, { quizId }) => {
|
|
35
|
-
await ctx.db.delete(quizId)
|
|
36
|
-
},
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
export const saveProgress = mutation({
|
|
40
|
-
args: {
|
|
41
|
-
quizId: v.id('quizzes'),
|
|
42
|
-
title: v.string(),
|
|
43
|
-
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
|
|
44
|
-
currentQuestion: v.number(),
|
|
45
|
-
completed: v.boolean(),
|
|
46
|
-
},
|
|
47
|
-
handler: async (ctx, { quizId, title, answers, currentQuestion, completed }) => {
|
|
48
|
-
await ctx.db.patch(quizId, { title, answers, currentQuestion, completed })
|
|
49
|
-
},
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
export const setQuestions = mutation({
|
|
53
|
-
args: {
|
|
54
|
-
quizId: v.id('quizzes'),
|
|
55
|
-
questionIds: v.array(v.string()),
|
|
56
|
-
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
|
|
57
|
-
currentQuestion: v.number(),
|
|
58
|
-
},
|
|
59
|
-
handler: async (ctx, { quizId, questionIds, answers, currentQuestion }) => {
|
|
60
|
-
await ctx.db.patch(quizId, { questionIds, answers, currentQuestion })
|
|
61
|
-
},
|
|
62
|
-
})
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { defineSchema, defineTable } from 'convex/server'
|
|
2
|
-
import { v } from 'convex/values'
|
|
3
|
-
|
|
4
|
-
export default defineSchema({
|
|
5
|
-
quizzes: defineTable({
|
|
6
|
-
title: v.string(),
|
|
7
|
-
questionIds: v.array(v.string()),
|
|
8
|
-
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
|
|
9
|
-
currentQuestion: v.number(),
|
|
10
|
-
completed: v.boolean(),
|
|
11
|
-
createdAt: v.number(),
|
|
12
|
-
}).index('by_created_at', ['createdAt']),
|
|
13
|
-
questionStatuses: defineTable({
|
|
14
|
-
questionId: v.string(),
|
|
15
|
-
status: v.union(v.literal('unanswered'), v.literal('revisit'), v.literal('complete')),
|
|
16
|
-
updatedAt: v.number(),
|
|
17
|
-
}).index('by_question_id', ['questionId']),
|
|
18
|
-
})
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// Typed Convex function references. No value imports — safe to eject.
|
|
2
|
-
// Framework-owned: eject with `qb reveal framework/lib/convex-api.ts`.
|
|
3
|
-
|
|
4
|
-
import { makeFunctionReference } from 'convex/server';
|
|
5
|
-
import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
|
|
6
|
-
|
|
7
|
-
type CreateQuizArgs = { title: string; questionIds: string[] };
|
|
8
|
-
type ProgressArgs = {
|
|
9
|
-
quizId: string;
|
|
10
|
-
title: string;
|
|
11
|
-
answers: Record<string, QuizAnswer>;
|
|
12
|
-
currentQuestion: number;
|
|
13
|
-
completed: boolean;
|
|
14
|
-
};
|
|
15
|
-
type SetQuestionsArgs = {
|
|
16
|
-
quizId: string;
|
|
17
|
-
questionIds: string[];
|
|
18
|
-
answers: Record<string, QuizAnswer>;
|
|
19
|
-
currentQuestion: number;
|
|
20
|
-
};
|
|
21
|
-
type QuizDocument = QuizSession & { _id: string; _creationTime: number };
|
|
22
|
-
type QuestionStatusDocument = {
|
|
23
|
-
_id: string;
|
|
24
|
-
_creationTime: number;
|
|
25
|
-
questionId: string;
|
|
26
|
-
status: QuestionStatus;
|
|
27
|
-
updatedAt: number;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export const convexApi = {
|
|
31
|
-
quizzes: {
|
|
32
|
-
create: makeFunctionReference<'mutation', CreateQuizArgs, string>('quizzes:create'),
|
|
33
|
-
get: makeFunctionReference<'query', { quizId: string }, QuizDocument | null>('quizzes:get'),
|
|
34
|
-
list: makeFunctionReference<'query', Record<string, never>, QuizDocument[]>('quizzes:list'),
|
|
35
|
-
remove: makeFunctionReference<'mutation', { quizId: string }, null>('quizzes:remove'),
|
|
36
|
-
saveProgress: makeFunctionReference<'mutation', ProgressArgs, null>('quizzes:saveProgress'),
|
|
37
|
-
setQuestions: makeFunctionReference<'mutation', SetQuestionsArgs, null>('quizzes:setQuestions'),
|
|
38
|
-
},
|
|
39
|
-
questionStatuses: {
|
|
40
|
-
list: makeFunctionReference<'query', Record<string, never>, QuestionStatusDocument[]>('questionStatuses:list'),
|
|
41
|
-
set: makeFunctionReference<'mutation', { questionId: string; status: QuestionStatus }, number>('questionStatuses:set'),
|
|
42
|
-
},
|
|
43
|
-
};
|