question-bank-framework 0.0.0-stage → 0.1.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 +105 -2
- package/bin/qb.js +119 -0
- package/bin/qb.ts +128 -0
- package/core/config.tsx +38 -0
- package/core/index.ts +17 -0
- package/core/types.ts +111 -0
- package/framework/components/app-shell.tsx +54 -0
- package/framework/components/filter-builder.tsx +471 -0
- package/framework/components/markdown-content.tsx +109 -0
- package/framework/components/question-bank.tsx +654 -0
- package/framework/components/quiz-list.tsx +110 -0
- package/framework/components/quiz-page.tsx +240 -0
- package/framework/components/ui/badge.tsx +47 -0
- package/framework/components/ui/button.tsx +63 -0
- package/framework/components/ui/checkbox.tsx +29 -0
- package/framework/components/ui/dropdown-menu.tsx +256 -0
- package/framework/components/ui/input.tsx +20 -0
- package/framework/components/ui/popover.tsx +88 -0
- package/framework/components/ui/select.tsx +187 -0
- package/framework/components/ui/sheet.tsx +140 -0
- package/framework/components/ui/table.tsx +121 -0
- package/framework/convex/questionStatuses.ts +30 -0
- package/framework/convex/quizzes.ts +50 -0
- package/framework/convex/schema.ts +18 -0
- package/framework/index.ts +26 -0
- package/framework/lib/convex-api.ts +36 -0
- package/framework/lib/question-filter-store.ts +116 -0
- package/framework/lib/question-filter.ts +362 -0
- package/framework/lib/question-status-storage.ts +117 -0
- package/framework/lib/quiz-storage.ts +118 -0
- package/framework/lib/utils.ts +6 -0
- package/package.json +65 -3
- package/template/bank.config.ts +72 -0
- package/template/content/answers.ts +6 -0
- package/template/content/questions.json +35 -0
- package/template/src/routes/__root.tsx +44 -0
- package/template/src/routes/index.tsx +4 -0
- package/template/src/routes/quiz.$quizId.tsx +4 -0
- package/template/src/routes/quizzes.tsx +4 -0
package/README.md
CHANGED
|
@@ -1,3 +1,106 @@
|
|
|
1
|
-
#
|
|
1
|
+
# question-bank-framework
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Framework-style question bank (generalised from the COMP20008 bank). Downstream
|
|
4
|
+
subjects bring their own `bank.config.ts` + `content/`; the framework ships the
|
|
5
|
+
grid, filter DSL, quizzes, and Convex bindings as an npm package.
|
|
6
|
+
|
|
7
|
+
Inspired by Vike's eject model: the stable core (`core/`) is never ejected;
|
|
8
|
+
everything under `framework/` can be ejected into your repo and owned.
|
|
9
|
+
|
|
10
|
+
## Use as a template
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm add question-bank-framework
|
|
14
|
+
pnpm exec qb init # scaffolds bank.config.ts + content/ + src/routes/
|
|
15
|
+
pnpm install
|
|
16
|
+
pnpm dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Your app owns:
|
|
20
|
+
|
|
21
|
+
- `bank.config.ts` — `defineBank({ site, namespace, fields, questions, answers })`.
|
|
22
|
+
`fields: BankFieldDef[]` **is** your question schema: each field declares
|
|
23
|
+
`id/label/type/getValue/options/searchable/column`. Built-ins (`id/title/tags/status`) are automatic.
|
|
24
|
+
- `content/questions.json`, `content/answers.ts`, `content/figures/`
|
|
25
|
+
- Thin TanStack Start routes in `src/routes/` (see `template/src/routes/`)
|
|
26
|
+
wrapping `<BankProvider bank={bank}>` + `<AppShell brand>` + framework pages.
|
|
27
|
+
|
|
28
|
+
## Reveal (eject) files
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
qb list # ejectable files under framework/
|
|
32
|
+
qb reveal framework/components/quiz-page.tsx
|
|
33
|
+
qb reveal framework/components/quiz-page.tsx --to src/overrides --force
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This copies the file into your repo (tracked in `.qb-manifest.json` with the
|
|
37
|
+
framework version). Add an alias so the override shadows the package:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// vite.config.ts + tsconfig paths
|
|
41
|
+
resolve: { alias: { 'question-bank-framework/framework': './src/overrides' } }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Eject is a last resort (same guidance as Vike): prefer `bank.config.ts`
|
|
45
|
+
fields/columns/searchable flags first; eject when you need custom UI.
|
|
46
|
+
|
|
47
|
+
## How Next.js / Vike do it (and what we copied)
|
|
48
|
+
|
|
49
|
+
- **Next.js:** no eject command. Framework code stays hidden; you customize via
|
|
50
|
+
`next.config.js` + file conventions (`app/layout.tsx`, `error.tsx`,
|
|
51
|
+
`loading.tsx` override built-in fallbacks just by existing). Deep eject
|
|
52
|
+
(custom server/webpack fork) is possible but discouraged.
|
|
53
|
+
- **Vike:** tiny stable core + thin extensions (`vike-react`…) using the same
|
|
54
|
+
public hooks as user code. Eject is first-class: remove the extension, copy
|
|
55
|
+
`node_modules/vike-xxx/` → `ejected/vike-xxx/` (manually or via the `eject`
|
|
56
|
+
package), then customize.
|
|
57
|
+
|
|
58
|
+
We follow Vike: `core/` (types, `defineBank`, `BankProvider`, `useBank`) is the
|
|
59
|
+
stable core; `framework/` (components/lib/routes/convex) is the ejectable
|
|
60
|
+
extension layer; `qb reveal` is the copy command.
|
|
61
|
+
|
|
62
|
+
## Layout
|
|
63
|
+
|
|
64
|
+
- `core/` — never ejected: `types.ts` (`BaseQuestion`, `BankFieldDef`,
|
|
65
|
+
`defineBank`), `config.tsx` (`BankProvider`, `useBank`).
|
|
66
|
+
- `framework/` — ejectable: `components/` (grid, quiz, filter builder,
|
|
67
|
+
shadcn `ui/`), `lib/` (generic filter DSL, namespaced quiz/status stores,
|
|
68
|
+
`convex-api.ts`), `convex/` (quizzes + questionStatuses tables).
|
|
69
|
+
- `template/` — starter: `bank.config.ts` (COMP20008-like schema),
|
|
70
|
+
`content/` example, `src/routes/` thin wrappers.
|
|
71
|
+
- `bin/qb.ts` (source; compiled to `bin/qb.js` with `pnpm build:bin`) — `list` / `reveal` / `init`.
|
|
72
|
+
|
|
73
|
+
## Schema example
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
defineBank({
|
|
77
|
+
site: { title: '…', brand: 'fieldnotes' },
|
|
78
|
+
namespace: 'comp20008', // localStorage keys + Convex lookups
|
|
79
|
+
fields: [
|
|
80
|
+
{ id: 'year', label: 'Year', type: 'select', getValue: (q) => String(q['year'] ?? '') },
|
|
81
|
+
{ id: 'marks', label: 'Marks', type: 'number', getValue: (q) => q['marks'] ?? null },
|
|
82
|
+
],
|
|
83
|
+
questions, // BaseQuestion[]: { id, title, prompt, tags, ...custom }
|
|
84
|
+
answers, // Record<id, markdown>
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Dev
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
pnpm install
|
|
92
|
+
pnpm typecheck
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Release (npm + changesets)
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
pnpm changeset # describe your change as patch/minor/major, then push
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Pushing to `main` runs the release workflow: it opens a "Version Packages"
|
|
102
|
+
PR (bump + CHANGELOG). Merge that PR and the workflow publishes to npm with
|
|
103
|
+
OIDC trusted publishing and provenance attestations — no npm token needed.
|
|
104
|
+
|
|
105
|
+
One-time setup: on npmjs.com, add this repo as a trusted publisher for the
|
|
106
|
+
`question-bank-framework` package (no tokens or secrets to manage).
|
package/bin/qb.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `qb` — Vike-style eject CLI for question-bank-framework.
|
|
3
|
+
// qb list list ejectable framework files
|
|
4
|
+
// qb reveal <path> [--to <dir>] copy framework/<path> into your repo to own it
|
|
5
|
+
// qb init [--to <dir>] scaffold bank.config.ts + content/ + routes
|
|
6
|
+
//
|
|
7
|
+
// Eject model (like Vike's `eject` package): framework code ships in
|
|
8
|
+
// node_modules/question-bank-framework/framework/. By default you import it.
|
|
9
|
+
// `qb reveal` copies a file into your project (default: src/overrides/<path>)
|
|
10
|
+
// where your Vite/TS alias shadows the package. Edit freely afterwards.
|
|
11
|
+
//
|
|
12
|
+
// NOTE: the installed bin is plain JS compiled from this file
|
|
13
|
+
// (`pnpm build:bin`) — Node refuses to type-strip files under node_modules,
|
|
14
|
+
// so bin/qb.js must stay committed alongside this source.
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const pkgRoot = resolve(here, '..');
|
|
20
|
+
const frameworkDir = join(pkgRoot, 'framework');
|
|
21
|
+
const templateDir = join(pkgRoot, 'template');
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const cmd = args[0];
|
|
24
|
+
function walk(dir, base = '') {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const entry of readdirSync(dir)) {
|
|
27
|
+
const full = join(dir, entry);
|
|
28
|
+
const rel = base ? `${base}/${entry}` : entry;
|
|
29
|
+
if (statSync(full).isDirectory())
|
|
30
|
+
out.push(...walk(full, rel));
|
|
31
|
+
else
|
|
32
|
+
out.push(rel);
|
|
33
|
+
}
|
|
34
|
+
return out.sort();
|
|
35
|
+
}
|
|
36
|
+
function pkgVersion() {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
|
|
39
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return '0.0.0';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function readManifest(cwd) {
|
|
46
|
+
const path = join(cwd, '.qb-manifest.json');
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
49
|
+
if (parsed && typeof parsed.reveals === 'object' && parsed.reveals !== null) {
|
|
50
|
+
return { reveals: parsed.reveals };
|
|
51
|
+
}
|
|
52
|
+
return { reveals: {} };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { reveals: {} };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function writeManifest(cwd, manifest) {
|
|
59
|
+
writeFileSync(join(cwd, '.qb-manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
60
|
+
}
|
|
61
|
+
if (cmd === 'list' || !cmd) {
|
|
62
|
+
if (!existsSync(frameworkDir)) {
|
|
63
|
+
console.error(`framework/ not found at ${frameworkDir}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
for (const f of walk(frameworkDir))
|
|
67
|
+
console.log(`framework/${f}`);
|
|
68
|
+
if (!cmd) {
|
|
69
|
+
console.log('\nUsage: qb reveal <framework/<path>> [--to <dir>] [--force]');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (cmd === 'reveal') {
|
|
73
|
+
const src = args[1];
|
|
74
|
+
if (!src) {
|
|
75
|
+
console.error('Usage: qb reveal <framework/<path>> [--to <dir>] [--force]');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const toIdx = args.indexOf('--to');
|
|
79
|
+
const force = args.includes('--force');
|
|
80
|
+
const destRoot = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : 'src/overrides';
|
|
81
|
+
const rel = src.replace(/^framework\//, '');
|
|
82
|
+
const absSrc = join(frameworkDir, rel);
|
|
83
|
+
if (!existsSync(absSrc) || !statSync(absSrc).isFile()) {
|
|
84
|
+
console.error(`Not ejectable: ${src}\nRun \`qb list\` to see ejectable files.`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const cwd = process.cwd();
|
|
88
|
+
const dest = resolve(cwd, destRoot, rel);
|
|
89
|
+
if (existsSync(dest) && !force) {
|
|
90
|
+
console.error(`Refusing to overwrite ${dest} (pass --force).`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
94
|
+
cpSync(absSrc, dest);
|
|
95
|
+
const manifest = readManifest(cwd);
|
|
96
|
+
manifest.reveals[`${destRoot}/${rel}`] = { src: `framework/${rel}`, version: pkgVersion(), revealedAt: new Date().toISOString() };
|
|
97
|
+
writeManifest(cwd, manifest);
|
|
98
|
+
console.log(`Revealed ${`framework/${rel}`} -> ${dest}`);
|
|
99
|
+
console.log('Add an alias so the override shadows the package, e.g. Vite resolve.alias + tsconfig paths:');
|
|
100
|
+
console.log(` "${destRoot}/*" takes precedence over "question-bank-framework/framework/*"`);
|
|
101
|
+
}
|
|
102
|
+
else if (cmd === 'init') {
|
|
103
|
+
const toIdx = args.indexOf('--to');
|
|
104
|
+
const destRoot = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : '.';
|
|
105
|
+
const cwd = resolve(process.cwd(), destRoot);
|
|
106
|
+
for (const f of walk(templateDir)) {
|
|
107
|
+
const from = join(templateDir, f);
|
|
108
|
+
const to = join(cwd, f);
|
|
109
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
110
|
+
if (!existsSync(to))
|
|
111
|
+
cpSync(from, to);
|
|
112
|
+
}
|
|
113
|
+
console.log(`Scaffolded template into ${cwd}`);
|
|
114
|
+
console.log('Next: pnpm install, then point your routes at bank.config.ts (see template/src/routes/).');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
console.error(`Unknown command: ${cmd}\nCommands: list, reveal, init`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
package/bin/qb.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `qb` — Vike-style eject CLI for question-bank-framework.
|
|
3
|
+
// qb list list ejectable framework files
|
|
4
|
+
// qb reveal <path> [--to <dir>] copy framework/<path> into your repo to own it
|
|
5
|
+
// qb init [--to <dir>] scaffold bank.config.ts + content/ + routes
|
|
6
|
+
//
|
|
7
|
+
// Eject model (like Vike's `eject` package): framework code ships in
|
|
8
|
+
// node_modules/question-bank-framework/framework/. By default you import it.
|
|
9
|
+
// `qb reveal` copies a file into your project (default: src/overrides/<path>)
|
|
10
|
+
// where your Vite/TS alias shadows the package. Edit freely afterwards.
|
|
11
|
+
//
|
|
12
|
+
// NOTE: the installed bin is plain JS compiled from this file
|
|
13
|
+
// (`pnpm build:bin`) — Node refuses to type-strip files under node_modules,
|
|
14
|
+
// so bin/qb.js must stay committed alongside this source.
|
|
15
|
+
|
|
16
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const here: string = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const pkgRoot: string = resolve(here, '..');
|
|
22
|
+
const frameworkDir: string = join(pkgRoot, 'framework');
|
|
23
|
+
const templateDir: string = join(pkgRoot, 'template');
|
|
24
|
+
|
|
25
|
+
const args: string[] = process.argv.slice(2);
|
|
26
|
+
const cmd: string | undefined = args[0];
|
|
27
|
+
|
|
28
|
+
function walk(dir: string, base = ''): string[] {
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
for (const entry of readdirSync(dir)) {
|
|
31
|
+
const full: string = join(dir, entry);
|
|
32
|
+
const rel: string = base ? `${base}/${entry}` : entry;
|
|
33
|
+
if (statSync(full).isDirectory()) out.push(...walk(full, rel));
|
|
34
|
+
else out.push(rel);
|
|
35
|
+
}
|
|
36
|
+
return out.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pkgVersion(): string {
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version?: unknown };
|
|
42
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
43
|
+
} catch {
|
|
44
|
+
return '0.0.0';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RevealEntry {
|
|
49
|
+
src: string;
|
|
50
|
+
version: string;
|
|
51
|
+
revealedAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface QbManifest {
|
|
55
|
+
reveals: Record<string, RevealEntry>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readManifest(cwd: string): QbManifest {
|
|
59
|
+
const path = join(cwd, '.qb-manifest.json');
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<QbManifest>;
|
|
62
|
+
if (parsed && typeof parsed.reveals === 'object' && parsed.reveals !== null) {
|
|
63
|
+
return { reveals: parsed.reveals };
|
|
64
|
+
}
|
|
65
|
+
return { reveals: {} };
|
|
66
|
+
} catch {
|
|
67
|
+
return { reveals: {} };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeManifest(cwd: string, manifest: QbManifest): void {
|
|
72
|
+
writeFileSync(join(cwd, '.qb-manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (cmd === 'list' || !cmd) {
|
|
76
|
+
if (!existsSync(frameworkDir)) {
|
|
77
|
+
console.error(`framework/ not found at ${frameworkDir}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
for (const f of walk(frameworkDir)) console.log(`framework/${f}`);
|
|
81
|
+
if (!cmd) {
|
|
82
|
+
console.log('\nUsage: qb reveal <framework/<path>> [--to <dir>] [--force]');
|
|
83
|
+
}
|
|
84
|
+
} else if (cmd === 'reveal') {
|
|
85
|
+
const src: string | undefined = args[1];
|
|
86
|
+
if (!src) {
|
|
87
|
+
console.error('Usage: qb reveal <framework/<path>> [--to <dir>] [--force]');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const toIdx: number = args.indexOf('--to');
|
|
91
|
+
const force: boolean = args.includes('--force');
|
|
92
|
+
const destRoot: string = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : 'src/overrides';
|
|
93
|
+
const rel: string = src.replace(/^framework\//, '');
|
|
94
|
+
const absSrc: string = join(frameworkDir, rel);
|
|
95
|
+
if (!existsSync(absSrc) || !statSync(absSrc).isFile()) {
|
|
96
|
+
console.error(`Not ejectable: ${src}\nRun \`qb list\` to see ejectable files.`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const cwd: string = process.cwd();
|
|
100
|
+
const dest: string = resolve(cwd, destRoot, rel);
|
|
101
|
+
if (existsSync(dest) && !force) {
|
|
102
|
+
console.error(`Refusing to overwrite ${dest} (pass --force).`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
106
|
+
cpSync(absSrc, dest);
|
|
107
|
+
const manifest: QbManifest = readManifest(cwd);
|
|
108
|
+
manifest.reveals[`${destRoot}/${rel}`] = { src: `framework/${rel}`, version: pkgVersion(), revealedAt: new Date().toISOString() };
|
|
109
|
+
writeManifest(cwd, manifest);
|
|
110
|
+
console.log(`Revealed ${`framework/${rel}`} -> ${dest}`);
|
|
111
|
+
console.log('Add an alias so the override shadows the package, e.g. Vite resolve.alias + tsconfig paths:');
|
|
112
|
+
console.log(` "${destRoot}/*" takes precedence over "question-bank-framework/framework/*"`);
|
|
113
|
+
} else if (cmd === 'init') {
|
|
114
|
+
const toIdx: number = args.indexOf('--to');
|
|
115
|
+
const destRoot: string = toIdx !== -1 && args[toIdx + 1] ? args[toIdx + 1] : '.';
|
|
116
|
+
const cwd: string = resolve(process.cwd(), destRoot);
|
|
117
|
+
for (const f of walk(templateDir)) {
|
|
118
|
+
const from: string = join(templateDir, f);
|
|
119
|
+
const to: string = join(cwd, f);
|
|
120
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
121
|
+
if (!existsSync(to)) cpSync(from, to);
|
|
122
|
+
}
|
|
123
|
+
console.log(`Scaffolded template into ${cwd}`);
|
|
124
|
+
console.log('Next: pnpm install, then point your routes at bank.config.ts (see template/src/routes/).');
|
|
125
|
+
} else {
|
|
126
|
+
console.error(`Unknown command: ${cmd}\nCommands: list, reveal, init`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
package/core/config.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Bank runtime context. Framework components consume the active bank via
|
|
2
|
+
// `useBank()` instead of importing `@/data/questions` directly, so a
|
|
3
|
+
// downstream subject only owns `bank.config.ts` + `content/`.
|
|
4
|
+
|
|
5
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
6
|
+
import type { Bank } from './types.ts';
|
|
7
|
+
|
|
8
|
+
export const BankContext = createContext<Bank | null>(null);
|
|
9
|
+
|
|
10
|
+
export function BankProvider({ bank, children }: { bank: Bank; children: ReactNode }) {
|
|
11
|
+
return <BankContext.Provider value={bank}>{children}</BankContext.Provider>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useBank(): Bank {
|
|
15
|
+
const bank = useContext(BankContext);
|
|
16
|
+
if (!bank) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'useBank() called outside <BankProvider>. Wrap your routes with <BankProvider bank={bank}>.',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return bank;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { defineBank } from './types.ts';
|
|
25
|
+
export type {
|
|
26
|
+
Bank,
|
|
27
|
+
BankFieldDef,
|
|
28
|
+
BankFieldOption,
|
|
29
|
+
BankFieldType,
|
|
30
|
+
BankFilterOperatorId,
|
|
31
|
+
BankSiteConfig,
|
|
32
|
+
BaseQuestion,
|
|
33
|
+
DefineBankInput,
|
|
34
|
+
QuestionStatus,
|
|
35
|
+
QuestionStatusMap,
|
|
36
|
+
QuizSession,
|
|
37
|
+
} from './types.ts';
|
|
38
|
+
export { questionStatuses } from './types.ts';
|
package/core/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Re-export the stable core: types + BankContext/useBank/defineBank.
|
|
2
|
+
// Framework code (ejectable) lives under framework/.
|
|
3
|
+
|
|
4
|
+
export { BankContext, BankProvider, useBank, defineBank, questionStatuses } from './config.tsx';
|
|
5
|
+
export type {
|
|
6
|
+
Bank,
|
|
7
|
+
BankFieldDef,
|
|
8
|
+
BankFieldOption,
|
|
9
|
+
BankFieldType,
|
|
10
|
+
BankFilterOperatorId,
|
|
11
|
+
BankSiteConfig,
|
|
12
|
+
BaseQuestion,
|
|
13
|
+
DefineBankInput,
|
|
14
|
+
QuestionStatus,
|
|
15
|
+
QuestionStatusMap,
|
|
16
|
+
QuizSession,
|
|
17
|
+
} from './config.tsx';
|
package/core/types.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Core question + bank types. Framework-owned, never ejected.
|
|
2
|
+
// A bank's question shape is config-defined: every question has the four
|
|
3
|
+
// core fields, plus arbitrary subject-specific metadata (year, marks, …).
|
|
4
|
+
|
|
5
|
+
export const questionStatuses = ['unanswered', 'revisit', 'complete'] as const;
|
|
6
|
+
|
|
7
|
+
export type QuestionStatus = (typeof questionStatuses)[number];
|
|
8
|
+
export type QuestionStatusMap = Record<string, QuestionStatus>;
|
|
9
|
+
|
|
10
|
+
export interface BaseQuestion {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
tags: string[];
|
|
15
|
+
// Subject-specific fields (e.g. year, semester, marks, source).
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface QuizSession {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
questionIds: string[];
|
|
23
|
+
answers: Record<string, string>;
|
|
24
|
+
currentQuestion: number;
|
|
25
|
+
completed: boolean;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Bank schema: config-defined fields.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export type BankFieldType = 'text' | 'number' | 'select' | 'multiselect';
|
|
34
|
+
|
|
35
|
+
export type BankFilterOperatorId =
|
|
36
|
+
| 'is'
|
|
37
|
+
| 'isNot'
|
|
38
|
+
| 'contains'
|
|
39
|
+
| 'doesNotContain'
|
|
40
|
+
| 'startsWith'
|
|
41
|
+
| 'endsWith'
|
|
42
|
+
| 'eq'
|
|
43
|
+
| 'neq'
|
|
44
|
+
| 'gt'
|
|
45
|
+
| 'lt'
|
|
46
|
+
| 'gte'
|
|
47
|
+
| 'lte'
|
|
48
|
+
| 'between'
|
|
49
|
+
| 'isEmpty'
|
|
50
|
+
| 'isNotEmpty';
|
|
51
|
+
|
|
52
|
+
export interface BankFieldOption {
|
|
53
|
+
value: string;
|
|
54
|
+
label: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BankFieldDef {
|
|
58
|
+
/** Stable field id. Reserved: id, title, tags, status. */
|
|
59
|
+
id: string;
|
|
60
|
+
label: string;
|
|
61
|
+
type: BankFieldType;
|
|
62
|
+
/** Extract the raw value from a question. */
|
|
63
|
+
getValue: (question: BaseQuestion) => string | number | null | readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* Fixed options for select/multiselect. When omitted, options are derived
|
|
66
|
+
* from distinct values across the bank's questions (sorted).
|
|
67
|
+
*/
|
|
68
|
+
options?: readonly BankFieldOption[];
|
|
69
|
+
placeholder?: string;
|
|
70
|
+
/** Include in the free-text search index. Defaults to false (id/title/prompt/tags always searched). */
|
|
71
|
+
searchable?: boolean;
|
|
72
|
+
/** Allowed operators. Defaults per type (same as Notion-style builder). */
|
|
73
|
+
operators?: readonly BankFilterOperatorId[];
|
|
74
|
+
/** Table column behaviour. `false` hides the field from the grid. */
|
|
75
|
+
column?: false | { header?: string; width?: number; sortable?: boolean };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface BankSiteConfig {
|
|
79
|
+
title: string;
|
|
80
|
+
/** Header brand text. */
|
|
81
|
+
brand: string;
|
|
82
|
+
description?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DefineBankInput {
|
|
86
|
+
site: BankSiteConfig;
|
|
87
|
+
/** Namespaces localStorage keys + Convex lookups. e.g. "comp20008". */
|
|
88
|
+
namespace: string;
|
|
89
|
+
/** Subject-specific schema. Built-ins (id/title/tags/status) are automatic. */
|
|
90
|
+
fields: BankFieldDef[];
|
|
91
|
+
questions: BaseQuestion[];
|
|
92
|
+
/** Suggested answers keyed by question id. Missing entries hide "Show answer". */
|
|
93
|
+
answers?: Record<string, string>;
|
|
94
|
+
/** Default sort for the grid, e.g. [{ id: "exam", desc: true }]. */
|
|
95
|
+
defaultSorting?: Array<{ id: string; desc?: boolean }>;
|
|
96
|
+
/** Extra per-question figures, resolved by prompt `` etc. */
|
|
97
|
+
publicPath?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface Bank extends DefineBankInput {
|
|
101
|
+
answers: Record<string, string>;
|
|
102
|
+
questionById: Map<string, BaseQuestion>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function defineBank(input: DefineBankInput): Bank {
|
|
106
|
+
return {
|
|
107
|
+
...input,
|
|
108
|
+
answers: input.answers ?? {},
|
|
109
|
+
questionById: new Map(input.questions.map((q) => [q.id, q])),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Framework-owned: eject with `qb reveal framework/components/app-shell.tsx`.
|
|
2
|
+
// Generic header: brand + nav come from the bank, not hardcoded "fieldnotes".
|
|
3
|
+
|
|
4
|
+
import { Link, useRouterState } from '@tanstack/react-router';
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
import { cn } from '../lib/utils.ts';
|
|
7
|
+
|
|
8
|
+
export function AppShell({
|
|
9
|
+
children,
|
|
10
|
+
brand,
|
|
11
|
+
homeLabel = 'Question bank',
|
|
12
|
+
quizzesLabel = 'My quizzes',
|
|
13
|
+
}: {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
brand: string;
|
|
16
|
+
homeLabel?: string;
|
|
17
|
+
quizzesLabel?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
20
|
+
const navigation = [
|
|
21
|
+
{ label: homeLabel, to: '/' },
|
|
22
|
+
{ label: quizzesLabel, to: '/quizzes' },
|
|
23
|
+
] as const;
|
|
24
|
+
const isQuestionBank = pathname === '/';
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={cn('flex flex-col bg-white', isQuestionBank ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
|
|
28
|
+
<header className="sticky top-0 z-20 shrink-0 border-b border-gray-200 bg-white">
|
|
29
|
+
<div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-8 px-4">
|
|
30
|
+
<Link to="/" className="text-lg font-bold text-gray-900">
|
|
31
|
+
{brand}
|
|
32
|
+
</Link>
|
|
33
|
+
<nav className="flex items-center gap-6" aria-label="Main navigation">
|
|
34
|
+
{navigation.map(({ label, to }) => {
|
|
35
|
+
const active = to === '/' ? pathname === '/' : pathname.startsWith(to);
|
|
36
|
+
return (
|
|
37
|
+
<Link
|
|
38
|
+
key={to}
|
|
39
|
+
to={to}
|
|
40
|
+
className={cn('text-sm font-semibold text-gray-600 hover:text-gray-900', active && 'text-gray-900 underline underline-offset-4')}
|
|
41
|
+
>
|
|
42
|
+
{label}
|
|
43
|
+
</Link>
|
|
44
|
+
);
|
|
45
|
+
})}
|
|
46
|
+
</nav>
|
|
47
|
+
</div>
|
|
48
|
+
</header>
|
|
49
|
+
<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')}>
|
|
50
|
+
{children}
|
|
51
|
+
</main>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|