question-bank-framework 0.0.0-stage → 0.1.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.
Files changed (39) hide show
  1. package/README.md +105 -2
  2. package/bin/qb.js +119 -0
  3. package/bin/qb.ts +128 -0
  4. package/core/config.tsx +52 -0
  5. package/core/index.ts +35 -0
  6. package/core/types.ts +256 -0
  7. package/framework/components/app-shell.tsx +54 -0
  8. package/framework/components/filter-builder.tsx +471 -0
  9. package/framework/components/markdown-content.tsx +109 -0
  10. package/framework/components/question-bank.tsx +690 -0
  11. package/framework/components/quiz-list.tsx +118 -0
  12. package/framework/components/quiz-page.tsx +454 -0
  13. package/framework/components/ui/badge.tsx +47 -0
  14. package/framework/components/ui/button.tsx +63 -0
  15. package/framework/components/ui/checkbox.tsx +29 -0
  16. package/framework/components/ui/dropdown-menu.tsx +256 -0
  17. package/framework/components/ui/input.tsx +20 -0
  18. package/framework/components/ui/popover.tsx +88 -0
  19. package/framework/components/ui/select.tsx +187 -0
  20. package/framework/components/ui/sheet.tsx +140 -0
  21. package/framework/components/ui/table.tsx +121 -0
  22. package/framework/convex/questionStatuses.ts +30 -0
  23. package/framework/convex/quizzes.ts +50 -0
  24. package/framework/convex/schema.ts +18 -0
  25. package/framework/index.ts +26 -0
  26. package/framework/lib/convex-api.ts +36 -0
  27. package/framework/lib/question-filter-store.ts +116 -0
  28. package/framework/lib/question-filter.ts +376 -0
  29. package/framework/lib/question-status-storage.ts +117 -0
  30. package/framework/lib/quiz-storage.ts +118 -0
  31. package/framework/lib/utils.ts +6 -0
  32. package/package.json +66 -4
  33. package/template/bank.config.ts +72 -0
  34. package/template/content/answers.ts +8 -0
  35. package/template/content/questions.json +76 -0
  36. package/template/src/routes/__root.tsx +44 -0
  37. package/template/src/routes/index.tsx +4 -0
  38. package/template/src/routes/quiz.$quizId.tsx +4 -0
  39. package/template/src/routes/quizzes.tsx +4 -0
@@ -0,0 +1,140 @@
1
+ import * as React from "react"
2
+ import { cn } from '../../lib/utils.ts'
3
+ import { XIcon } from "lucide-react"
4
+ import { Dialog as SheetPrimitive } from "radix-ui"
5
+
6
+ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
7
+ return <SheetPrimitive.Root data-slot="sheet" {...props} />
8
+ }
9
+
10
+ function SheetTrigger({
11
+ ...props
12
+ }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
13
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
14
+ }
15
+
16
+ function SheetClose({
17
+ ...props
18
+ }: React.ComponentProps<typeof SheetPrimitive.Close>) {
19
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
20
+ }
21
+
22
+ function SheetPortal({
23
+ ...props
24
+ }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
25
+ return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
26
+ }
27
+
28
+ function SheetOverlay({
29
+ className,
30
+ ...props
31
+ }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
32
+ return (
33
+ <SheetPrimitive.Overlay
34
+ data-slot="sheet-overlay"
35
+ className={cn(
36
+ "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
37
+ className
38
+ )}
39
+ {...props}
40
+ />
41
+ )
42
+ }
43
+
44
+ function SheetContent({
45
+ className,
46
+ children,
47
+ side = "right",
48
+ showCloseButton = true,
49
+ ...props
50
+ }: React.ComponentProps<typeof SheetPrimitive.Content> & {
51
+ side?: "top" | "right" | "bottom" | "left"
52
+ showCloseButton?: boolean
53
+ }) {
54
+ return (
55
+ <SheetPortal>
56
+ <SheetOverlay />
57
+ <SheetPrimitive.Content
58
+ data-slot="sheet-content"
59
+ className={cn(
60
+ "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
61
+ side === "right" &&
62
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
63
+ side === "left" &&
64
+ "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
65
+ side === "top" &&
66
+ "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
67
+ side === "bottom" &&
68
+ "inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
69
+ className
70
+ )}
71
+ {...props}
72
+ >
73
+ {children}
74
+ {showCloseButton && (
75
+ <SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
76
+ <XIcon className="size-4" />
77
+ <span className="sr-only">Close</span>
78
+ </SheetPrimitive.Close>
79
+ )}
80
+ </SheetPrimitive.Content>
81
+ </SheetPortal>
82
+ )
83
+ }
84
+
85
+ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
86
+ return (
87
+ <div
88
+ data-slot="sheet-header"
89
+ className={cn("flex flex-col gap-1.5 p-4", className)}
90
+ {...props}
91
+ />
92
+ )
93
+ }
94
+
95
+ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
96
+ return (
97
+ <div
98
+ data-slot="sheet-footer"
99
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
100
+ {...props}
101
+ />
102
+ )
103
+ }
104
+
105
+ function SheetTitle({
106
+ className,
107
+ ...props
108
+ }: React.ComponentProps<typeof SheetPrimitive.Title>) {
109
+ return (
110
+ <SheetPrimitive.Title
111
+ data-slot="sheet-title"
112
+ className={cn("font-semibold text-foreground", className)}
113
+ {...props}
114
+ />
115
+ )
116
+ }
117
+
118
+ function SheetDescription({
119
+ className,
120
+ ...props
121
+ }: React.ComponentProps<typeof SheetPrimitive.Description>) {
122
+ return (
123
+ <SheetPrimitive.Description
124
+ data-slot="sheet-description"
125
+ className={cn("text-sm text-muted-foreground", className)}
126
+ {...props}
127
+ />
128
+ )
129
+ }
130
+
131
+ export {
132
+ Sheet,
133
+ SheetTrigger,
134
+ SheetClose,
135
+ SheetContent,
136
+ SheetHeader,
137
+ SheetFooter,
138
+ SheetTitle,
139
+ SheetDescription,
140
+ }
@@ -0,0 +1,121 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from '../../lib/utils.ts'
5
+
6
+ type TableProps = React.ComponentProps<"table"> & {
7
+ containerRef?: React.Ref<HTMLDivElement>
8
+ containerClassName?: string
9
+ }
10
+
11
+ function Table({ className, containerRef, containerClassName, ...props }: TableProps) {
12
+ return (
13
+ <div
14
+ ref={containerRef}
15
+ data-slot="table-container"
16
+ className={cn("relative w-full overflow-x-auto", containerClassName)}
17
+ >
18
+ <table
19
+ data-slot="table"
20
+ className={cn("w-full caption-bottom text-sm", className)}
21
+ {...props}
22
+ />
23
+ </div>
24
+ )
25
+ }
26
+
27
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
28
+ return (
29
+ <thead
30
+ data-slot="table-header"
31
+ className={cn("[&_tr]:border-b", className)}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
38
+ return (
39
+ <tbody
40
+ data-slot="table-body"
41
+ className={cn("[&_tr:last-child]:border-0", className)}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
48
+ return (
49
+ <tfoot
50
+ data-slot="table-footer"
51
+ className={cn(
52
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
53
+ className
54
+ )}
55
+ {...props}
56
+ />
57
+ )
58
+ }
59
+
60
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
61
+ return (
62
+ <tr
63
+ data-slot="table-row"
64
+ className={cn(
65
+ "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
66
+ className
67
+ )}
68
+ {...props}
69
+ />
70
+ )
71
+ }
72
+
73
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
74
+ return (
75
+ <th
76
+ data-slot="table-head"
77
+ className={cn(
78
+ "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
79
+ className
80
+ )}
81
+ {...props}
82
+ />
83
+ )
84
+ }
85
+
86
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
87
+ return (
88
+ <td
89
+ data-slot="table-cell"
90
+ className={cn(
91
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
92
+ className
93
+ )}
94
+ {...props}
95
+ />
96
+ )
97
+ }
98
+
99
+ function TableCaption({
100
+ className,
101
+ ...props
102
+ }: React.ComponentProps<"caption">) {
103
+ return (
104
+ <caption
105
+ data-slot="table-caption"
106
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
107
+ {...props}
108
+ />
109
+ )
110
+ }
111
+
112
+ export {
113
+ Table,
114
+ TableHeader,
115
+ TableBody,
116
+ TableFooter,
117
+ TableHead,
118
+ TableRow,
119
+ TableCell,
120
+ TableCaption,
121
+ }
@@ -0,0 +1,30 @@
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
+ })
@@ -0,0 +1,50 @@
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()))),
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
+ })
@@ -0,0 +1,18 @@
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()))),
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
+ })
@@ -0,0 +1,26 @@
1
+ // Public framework surface. Import from "question-bank-framework/framework".
2
+ // Every file under framework/ is ejectable via `qb reveal <path>`.
3
+
4
+ export { QuestionBank } from './components/question-bank.tsx';
5
+ export { QuizPage } from './components/quiz-page.tsx';
6
+ export { QuizList } from './components/quiz-list.tsx';
7
+ export { AppShell } from './components/app-shell.tsx';
8
+ export { FilterBuilder } from './components/filter-builder.tsx';
9
+ export { MarkdownContent } from './components/markdown-content.tsx';
10
+
11
+ export {
12
+ createFilterFields,
13
+ matchesFilters,
14
+ matchesSearch,
15
+ questionStatusLabels,
16
+ defaultOperatorByType,
17
+ fieldTypeLabels,
18
+ operatorLabels,
19
+ } from './lib/question-filter.ts';
20
+ export type { FilterField, FilterMatch, FilterOperatorId, FilterRule } from './lib/question-filter.ts';
21
+
22
+ export { createQuestionFilterStore, useQuestionFilterStore } from './lib/question-filter-store.ts';
23
+ export { createQuiz, deleteQuiz, getQuiz, listQuizzes, saveQuizLocally, saveQuizProgress } from './lib/quiz-storage.ts';
24
+ export { getQuestionStatuses, setQuestionStatus } from './lib/question-status-storage.ts';
25
+ export { convexApi } from './lib/convex-api.ts';
26
+ export { cn } from './lib/utils.ts';
@@ -0,0 +1,36 @@
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 QuizDocument = QuizSession & { _id: string; _creationTime: number };
16
+ type QuestionStatusDocument = {
17
+ _id: string;
18
+ _creationTime: number;
19
+ questionId: string;
20
+ status: QuestionStatus;
21
+ updatedAt: number;
22
+ };
23
+
24
+ export const convexApi = {
25
+ quizzes: {
26
+ create: makeFunctionReference<'mutation', CreateQuizArgs, string>('quizzes:create'),
27
+ get: makeFunctionReference<'query', { quizId: string }, QuizDocument | null>('quizzes:get'),
28
+ list: makeFunctionReference<'query', Record<string, never>, QuizDocument[]>('quizzes:list'),
29
+ remove: makeFunctionReference<'mutation', { quizId: string }, null>('quizzes:remove'),
30
+ saveProgress: makeFunctionReference<'mutation', ProgressArgs, null>('quizzes:saveProgress'),
31
+ },
32
+ questionStatuses: {
33
+ list: makeFunctionReference<'query', Record<string, never>, QuestionStatusDocument[]>('questionStatuses:list'),
34
+ set: makeFunctionReference<'mutation', { questionId: string; status: QuestionStatus }, number>('questionStatuses:set'),
35
+ },
36
+ };
@@ -0,0 +1,116 @@
1
+ // Persisted filter UI state, namespaced per bank.
2
+ // Framework-owned: eject with `qb reveal framework/lib/question-filter-store.ts`.
3
+
4
+ import { create } from 'zustand';
5
+ import { persist } from 'zustand/middleware';
6
+ import {
7
+ defaultOperatorByType,
8
+ filterFieldType,
9
+ type FilterField,
10
+ type FilterMatch,
11
+ type FilterOperatorId,
12
+ type FilterRule,
13
+ } from './question-filter.ts';
14
+
15
+ type QuestionFilterValues = {
16
+ search: string;
17
+ filters: FilterRule[];
18
+ filterMatch: FilterMatch;
19
+ };
20
+
21
+ type QuestionFilterActions = {
22
+ setSearch: (search: string) => void;
23
+ addFilter: (field: string, operator?: FilterOperatorId) => void;
24
+ updateFilter: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) => void;
25
+ removeFilter: (id: string) => void;
26
+ clearFilters: () => void;
27
+ setFilterMatch: (match: FilterMatch) => void;
28
+ };
29
+
30
+ type QuestionFilterStore = QuestionFilterValues & QuestionFilterActions;
31
+
32
+ let filterIdCounter = 0;
33
+
34
+ const createFilterId = () => {
35
+ const uuid = globalThis.crypto?.randomUUID?.();
36
+ if (uuid) return uuid;
37
+ filterIdCounter += 1;
38
+ return `filter-${Date.now()}-${filterIdCounter}`;
39
+ };
40
+
41
+ /**
42
+ * The store is created per bank namespace so two banks in one origin don't
43
+ * share filter state. Call once per app (e.g. in your BankProvider setup).
44
+ */
45
+ export function createQuestionFilterStore(namespace: string, fields: readonly FilterField[]) {
46
+ return create<QuestionFilterStore>()(
47
+ persist(
48
+ (set) => ({
49
+ search: '',
50
+ filters: [],
51
+ filterMatch: 'and',
52
+ setSearch: (search) => set({ search }),
53
+ addFilter: (field, operator) =>
54
+ set((state) => ({
55
+ filters: [
56
+ ...state.filters,
57
+ {
58
+ id: createFilterId(),
59
+ field,
60
+ operator: operator ?? defaultOperatorByType[filterFieldType(fields, field)],
61
+ value: [],
62
+ },
63
+ ],
64
+ })),
65
+ updateFilter: (id, patch) =>
66
+ set((state) => ({
67
+ filters: state.filters.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)),
68
+ })),
69
+ removeFilter: (id) =>
70
+ set((state) => ({
71
+ filters: state.filters.filter((rule) => rule.id !== id),
72
+ })),
73
+ clearFilters: () => set({ filters: [] }),
74
+ setFilterMatch: (filterMatch) => set({ filterMatch }),
75
+ }),
76
+ {
77
+ name: `${namespace}-question-bank-filters`,
78
+ version: 2,
79
+ skipHydration: true,
80
+ partialize: ({ search, filters, filterMatch }) => ({ search, filters, filterMatch }),
81
+ },
82
+ ),
83
+ );
84
+ }
85
+
86
+ /** Default singleton for single-bank apps. Create via `createQuestionFilterStore` for multi-bank. */
87
+ export const useQuestionFilterStore = create<QuestionFilterStore>()(
88
+ persist(
89
+ (set) => ({
90
+ search: '',
91
+ filters: [],
92
+ filterMatch: 'and' as FilterMatch,
93
+ setSearch: (search: string) => set({ search }),
94
+ addFilter: (field: string, operator?: FilterOperatorId) =>
95
+ set((state) => ({
96
+ filters: [...state.filters, { id: createFilterId(), field, operator: operator ?? 'contains', value: [] }],
97
+ })),
98
+ updateFilter: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) =>
99
+ set((state) => ({
100
+ filters: state.filters.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)),
101
+ })),
102
+ removeFilter: (id: string) =>
103
+ set((state) => ({
104
+ filters: state.filters.filter((rule) => rule.id !== id),
105
+ })),
106
+ clearFilters: () => set({ filters: [] }),
107
+ setFilterMatch: (filterMatch: FilterMatch) => set({ filterMatch }),
108
+ }),
109
+ {
110
+ name: 'question-bank-filters',
111
+ version: 2,
112
+ skipHydration: true,
113
+ partialize: ({ search, filters, filterMatch }) => ({ search, filters, filterMatch }),
114
+ },
115
+ ),
116
+ );