rogasper commited on
Commit
99d1f6e
Β·
1 Parent(s): cfc59e4

Add initial project structure with design and product documentation, API key management, sidebar navigation, and route setup

Browse files
.gitignore CHANGED
@@ -1,5 +1,8 @@
1
  # Dependencies
2
  node_modules
 
 
 
3
  .pnp
4
  .pnp.js
5
 
 
1
  # Dependencies
2
  node_modules
3
+ .agents
4
+ .claude
5
+ references
6
  .pnp
7
  .pnp.js
8
 
DESIGN.md ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design System Inspired by Clay
2
+
3
+ ## 1. Visual Theme & Atmosphere
4
+
5
+ Clay's website is a warm, playful celebration of color that treats B2B data enrichment like a craft rather than an enterprise chore. The design language is built on a foundation of warm cream backgrounds (`#faf9f7`) and oat-toned borders (`#dad4c8`, `#eee9df`) that give every surface the tactile quality of handmade paper. Against this artisanal canvas, a vivid swatch palette explodes with personality β€” Matcha green, Slushie cyan, Lemon gold, Ube purple, Pomegranate pink, Blueberry navy, and Dragonfruit magenta β€” each named like flavors at a juice bar, not colors in an enterprise UI kit.
6
+
7
+ The typography is anchored by Roobert, a geometric sans-serif with character, loaded with an extensive set of OpenType stylistic sets (`"ss01"`, `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"`) that give the text a distinctive, slightly quirky personality. At display scale (80px, weight 600), Roobert uses aggressive negative letter-spacing (-3.2px) that compresses headlines into punchy, billboard-like statements. Space Mono serves as the monospace companion for code and technical labels, completing the craft-meets-tech duality.
8
+
9
+ What makes Clay truly distinctive is its hover micro-animations: buttons on hover rotate slightly (`rotateZ(-8deg)`), translate upward (`translateY(-80%)`), change background to a contrasting swatch color, and cast a hard offset shadow (`rgb(0,0,0) -7px 7px`). This playful hover behavior β€” where a button literally tilts and jumps on interaction β€” creates a sense of physical delight that's rare in B2B software. Combined with generously rounded containers (24px–40px radius), dashed borders alongside solid ones, and a multi-layer shadow system that includes inset highlights, Clay feels like a design system that was made by people who genuinely enjoy making things.
10
+
11
+ **Key Characteristics:**
12
+ - Warm cream canvas (`#faf9f7`) with oat-toned borders (`#dad4c8`) β€” artisanal, not clinical
13
+ - Named swatch palette: Matcha, Slushie, Lemon, Ube, Pomegranate, Blueberry, Dragonfruit
14
+ - Roobert font with 5 OpenType stylistic sets β€” quirky geometric character
15
+ - Playful hover animations: rotateZ(-8deg) + translateY(-80%) + hard offset shadow
16
+ - Space Mono for code and technical labels
17
+ - Generous border radius: 24px cards, 40px sections, 1584px pills
18
+ - Mixed border styles: solid + dashed in the same interface
19
+ - Multi-layer shadow with inset highlight: `0px 1px 1px` + `-1px inset` + `-0.5px`
20
+
21
+ ## 2. Color Palette & Roles
22
+
23
+ ### Primary
24
+ - **Clay Black** (`#000000`): Text, headings, pricing card text, `--_theme--pricing-cards---text`
25
+ - **Pure White** (`#ffffff`): Card backgrounds, button backgrounds, inverse text
26
+ - **Warm Cream** (`#faf9f7`): Page background β€” the warm, paper-like canvas
27
+
28
+ ### Swatch Palette β€” Named Colors
29
+
30
+ **Matcha (Green)**
31
+ - **Matcha 300** (`#84e7a5`): `--_swatches---color--matcha-300`, light green accent
32
+ - **Matcha 600** (`#078a52`): `--_swatches---color--matcha-600`, mid green
33
+ - **Matcha 800** (`#02492a`): `--_swatches---color--matcha-800`, deep green for dark sections
34
+
35
+ **Slushie (Cyan)**
36
+ - **Slushie 500** (`#3bd3fd`): `--_swatches---color--slushie-500`, bright cyan accent
37
+ - **Slushie 800** (`#0089ad`): `--_swatches---color--slushie-800`, deep teal
38
+
39
+ **Lemon (Gold)**
40
+ - **Lemon 400** (`#f8cc65`): `--_swatches---color--lemon-400`, warm pale gold
41
+ - **Lemon 500** (`#fbbd41`): `--_swatches---color--lemon-500`, primary gold
42
+ - **Lemon 700** (`#d08a11`): `--_swatches---color--lemon-700`, deep amber
43
+ - **Lemon 800** (`#9d6a09`): `--_swatches---color--lemon-800`, dark amber
44
+
45
+ **Ube (Purple)**
46
+ - **Ube 300** (`#c1b0ff`): `--_swatches---color--ube-300`, soft lavender
47
+ - **Ube 800** (`#43089f`): `--_swatches---color--ube-800`, deep purple
48
+ - **Ube 900** (`#32037d`): `--_swatches---color--ube-900`, darkest purple
49
+
50
+ **Pomegranate (Pink/Red)**
51
+ - **Pomegranate 400** (`#fc7981`): `--_swatches---color--pomegranate-400`, warm coral-pink
52
+
53
+ **Blueberry (Navy Blue)**
54
+ - **Blueberry 800** (`#01418d`): `--_swatches---color--blueberry-800`, deep navy
55
+
56
+ ### Neutral Scale (Warm)
57
+ - **Warm Silver** (`#9f9b93`): Secondary/muted text, footer links
58
+ - **Warm Charcoal** (`#55534e`): Tertiary text, dark muted links
59
+ - **Dark Charcoal** (`#333333`): Link text on light backgrounds
60
+
61
+ ### Surface & Border
62
+ - **Oat Border** (`#dad4c8`): Primary border β€” warm, cream-toned structural lines
63
+ - **Oat Light** (`#eee9df`): Secondary lighter border
64
+ - **Cool Border** (`#e6e8ec`): Cool-toned border for contrast sections
65
+ - **Dark Border** (`#525a69`): Border on dark sections
66
+ - **Light Frost** (`#eff1f3`): Subtle button background (at 0% opacity on hover)
67
+
68
+ ### Badges
69
+ - **Badge Blue Bg** (`#f0f8ff`): Blue-tinted badge surface
70
+ - **Badge Blue Text** (`#3859f9`): Vivid blue badge text
71
+ - **Focus Ring** (`rgb(20, 110, 245) solid 2px`): Accessibility focus indicator
72
+
73
+ ### Shadows
74
+ - **Clay Shadow** (`rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px 1px inset, rgba(0,0,0,0.05) 0px -0.5px 1px`): Multi-layer with inset highlight β€” the signature
75
+ - **Hard Offset** (`rgb(0,0,0) -7px 7px`): Hover state β€” playful hard shadow
76
+
77
+ ## 3. Typography Rules
78
+
79
+ ### Font Families
80
+ - **Primary**: `Roobert`, fallback: `Arial`
81
+ - **Monospace**: `Space Mono`
82
+ - **OpenType Features**: `"ss01"`, `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"` on all Roobert text (display uses all 5; body/UI uses `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"`)
83
+
84
+ ### Hierarchy
85
+
86
+ | Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
87
+ |------|------|------|--------|-------------|----------------|-------|
88
+ | Display Hero | Roobert | 80px (5.00rem) | 600 | 1.00 (tight) | -3.2px | All 5 stylistic sets |
89
+ | Display Secondary | Roobert | 60px (3.75rem) | 600 | 1.00 (tight) | -2.4px | All 5 stylistic sets |
90
+ | Section Heading | Roobert | 44px (2.75rem) | 600 | 1.10 (tight) | -0.88px to -1.32px | All 5 stylistic sets |
91
+ | Card Heading | Roobert | 32px (2.00rem) | 600 | 1.10 (tight) | -0.64px | All 5 stylistic sets |
92
+ | Feature Title | Roobert | 20px (1.25rem) | 600 | 1.40 | -0.4px | All 5 stylistic sets |
93
+ | Sub-heading | Roobert | 20px (1.25rem) | 500 | 1.50 | -0.16px | 4 stylistic sets (no ss01) |
94
+ | Body Large | Roobert | 20px (1.25rem) | 400 | 1.40 | normal | 4 stylistic sets |
95
+ | Body | Roobert | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.36px | 4 stylistic sets |
96
+ | Body Standard | Roobert | 16px (1.00rem) | 400 | 1.50 | normal | 4 stylistic sets |
97
+ | Body Medium | Roobert | 16px (1.00rem) | 500 | 1.20–1.40 | -0.16px to -0.32px | 4–5 stylistic sets |
98
+ | Button | Roobert | 16px (1.00rem) | 500 | 1.50 | -0.16px | 4 stylistic sets |
99
+ | Button Large | Roobert | 24px (1.50rem) | 400 | 1.50 | normal | 4 stylistic sets |
100
+ | Button Small | Roobert | 12.8px (0.80rem) | 500 | 1.50 | -0.128px | 4 stylistic sets |
101
+ | Nav Link | Roobert | 15px (0.94rem) | 500 | 1.60 (relaxed) | normal | 4 stylistic sets |
102
+ | Caption | Roobert | 14px (0.88rem) | 400 | 1.50–1.60 | -0.14px | 4 stylistic sets |
103
+ | Small | Roobert | 12px (0.75rem) | 400 | 1.50 | normal | 4 stylistic sets |
104
+ | Uppercase Label | Roobert | 12px (0.75rem) | 600 | 1.20 (tight) | 1.08px | `text-transform: uppercase`, 4 sets |
105
+ | Badge | Roobert | 9.6px | 600 | β€” | β€” | Pill badges |
106
+
107
+ ### Principles
108
+ - **Five stylistic sets as identity**: The combination of `"ss01"`, `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"` on Roobert creates a distinctive typographic personality. `ss01` is reserved for headings and emphasis β€” body text omits it, creating a subtle hierarchy through glyph variation.
109
+ - **Aggressive display compression**: -3.2px at 80px, -2.4px at 60px β€” the most compressed display tracking alongside the most generous body spacing (1.60 line-height), creating dramatic contrast.
110
+ - **Weight 600 for headings, 500 for UI, 400 for body**: Clean three-tier system where each weight has a strict role.
111
+ - **Uppercase labels with positive tracking**: 12px uppercase at 1.08px letter-spacing creates the systematic wayfinding pattern.
112
+
113
+ ## 4. Component Stylings
114
+
115
+ ### Buttons
116
+
117
+ **Primary (Transparent with Hover Animation)**
118
+ - Background: transparent (`rgba(239, 241, 243, 0)`)
119
+ - Text: `#000000`
120
+ - Padding: 6.4px 12.8px
121
+ - Border: none (or `1px solid #717989` for outlined variant)
122
+ - Hover: background shifts to swatch color (e.g., `#434346`), text to white, `rotateZ(-8deg)`, `translateY(-80%)`, hard shadow `rgb(0,0,0) -7px 7px`
123
+ - Focus: `rgb(20, 110, 245) solid 2px` outline
124
+
125
+ **White Solid**
126
+ - Background: `#ffffff`
127
+ - Text: `#000000`
128
+ - Padding: 6.4px
129
+ - Hover: oat-200 swatch color, animated rotation + shadow
130
+ - Use: Primary CTA on colored sections
131
+
132
+ **Ghost Outlined**
133
+ - Background: transparent
134
+ - Text: `#000000`
135
+ - Padding: 8px
136
+ - Border: `1px solid #717989`
137
+ - Radius: 4px
138
+ - Hover: dragonfruit swatch color, white text, animated rotation
139
+
140
+ ### Cards & Containers
141
+ - Background: `#ffffff` on cream canvas
142
+ - Border: `1px solid #dad4c8` (warm oat) or `1px dashed #dad4c8`
143
+ - Radius: 12px (standard cards), 24px (feature cards/images), 40px (section containers/footer)
144
+ - Shadow: `rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px 1px inset, rgba(0,0,0,0.05) 0px -0.5px 1px`
145
+ - Colorful section backgrounds using swatch palette (matcha, slushie, ube, lemon)
146
+
147
+ ### Inputs & Forms
148
+ - Text: `#000000`
149
+ - Border: `1px solid #717989`
150
+ - Radius: 4px
151
+ - Focus: `rgb(20, 110, 245) solid 2px` outline
152
+
153
+ ### Navigation
154
+ - Sticky top nav on cream background
155
+ - Roobert 15px weight 500 for nav links
156
+ - Clay logo left-aligned
157
+ - CTA buttons right-aligned with pill radius
158
+ - Border bottom: `1px solid #dad4c8`
159
+ - Mobile: hamburger collapse at 767px
160
+
161
+ ### Image Treatment
162
+ - Product screenshots in white cards with oat borders
163
+ - Colorful illustrated sections with swatch background colors
164
+ - 8px–24px radius on images
165
+ - Full-width colorful section backgrounds
166
+
167
+ ### Distinctive Components
168
+
169
+ **Swatch Color Sections**
170
+ - Full-width sections with swatch-colored backgrounds (matcha green, slushie cyan, ube purple, lemon gold)
171
+ - White text on dark swatches, black text on light swatches
172
+ - Each section tells a distinct product story through its color
173
+
174
+ **Playful Hover Buttons**
175
+ - Rotate -8deg + translate upward on hover
176
+ - Hard offset shadow (`-7px 7px`) instead of soft blur
177
+ - Background transitions to contrasting swatch color
178
+ - Creates a physical, toy-like interaction quality
179
+
180
+ **Dashed Border Elements**
181
+ - Dashed borders (`1px dashed #dad4c8`) alongside solid borders
182
+ - Used for secondary containers and decorative elements
183
+ - Adds a hand-drawn, craft-like quality
184
+
185
+ ## 5. Layout Principles
186
+
187
+ ### Spacing System
188
+ - Base unit: 8px
189
+ - Scale: 1px, 2px, 4px, 6.4px, 8px, 12px, 12.8px, 16px, 18px, 20px, 24px
190
+
191
+ ### Grid & Container
192
+ - Max content width centered
193
+ - Feature sections alternate between white cards and colorful swatch backgrounds
194
+ - Card grids: 2–3 columns on desktop
195
+ - Full-width colorful sections break the grid
196
+ - Footer with generous 40px radius container
197
+
198
+ ### Whitespace Philosophy
199
+ - **Warm, generous breathing**: The cream background provides a warm rest between content blocks. Spacing is generous but not austere β€” it feels inviting, like a well-set table.
200
+ - **Color as spatial rhythm**: The alternating swatch-colored sections create visual rhythm through hue rather than just whitespace. Each color section is its own "room."
201
+ - **Craft-like density inside cards**: Within cards, content is compact and well-organized, contrasting with the generous outer spacing.
202
+
203
+ ### Border Radius Scale
204
+ - Sharp (4px): Ghost buttons, inputs
205
+ - Standard (8px): Small cards, images, links
206
+ - Badge (11px): Tag badges
207
+ - Card (12px): Standard cards, buttons
208
+ - Feature (24px): Feature cards, images, panels
209
+ - Section (40px): Large sections, footer, containers
210
+ - Pill (1584px): CTAs, pill-shaped buttons
211
+
212
+ ## 6. Depth & Elevation
213
+
214
+ | Level | Treatment | Use |
215
+ |-------|-----------|-----|
216
+ | Flat (Level 0) | No shadow, cream canvas | Page background |
217
+ | Clay Shadow (Level 1) | `rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px inset, rgba(0,0,0,0.05) 0px -0.5px` | Cards, buttons β€” multi-layer with inset highlight |
218
+ | Hover Hard (Level 2) | `rgb(0,0,0) -7px 7px` | Hover state β€” playful hard offset shadow |
219
+ | Focus (Level 3) | `rgb(20, 110, 245) solid 2px` | Keyboard focus ring |
220
+
221
+ **Shadow Philosophy**: Clay's shadow system is uniquely three-layered: a downward cast (`0px 1px 1px`), an upward inset highlight (`0px -1px 1px inset`), and a subtle edge (`0px -0.5px 1px`). This creates a "pressed into clay" quality where elements feel both raised AND embedded β€” like a clay tablet where content is stamped into the surface. The hover hard shadow (`-7px 7px`) is deliberately retro-graphic, referencing print-era drop shadows and adding physical playfulness.
222
+
223
+ ### Decorative Depth
224
+ - Full-width swatch-colored sections create dramatic depth through color contrast
225
+ - Dashed borders add visual texture alongside solid borders
226
+ - Product illustrations with warm, organic art style
227
+
228
+ ## 7. Do's and Don'ts
229
+
230
+ ### Do
231
+ - Use warm cream (`#faf9f7`) as the page background β€” the warmth is the identity
232
+ - Apply all 5 OpenType stylistic sets on Roobert headings: `"ss01", "ss03", "ss10", "ss11", "ss12"`
233
+ - Use the named swatch palette (Matcha, Slushie, Lemon, Ube, Pomegranate, Blueberry) for section backgrounds
234
+ - Apply the playful hover animation: `rotateZ(-8deg)`, `translateY(-80%)`, hard shadow `-7px 7px`
235
+ - Use warm oat borders (`#dad4c8`) β€” not neutral gray
236
+ - Mix solid and dashed borders for visual variety
237
+ - Use generous radius: 24px for cards, 40px for sections
238
+ - Use weight 600 exclusively for headings, 500 for UI, 400 for body
239
+
240
+ ### Don't
241
+ - Don't use cool gray backgrounds β€” the warm cream (`#faf9f7`) is non-negotiable
242
+ - Don't use neutral gray borders (`#ccc`, `#ddd`) β€” always use the warm oat tones
243
+ - Don't mix more than 2 swatch colors in the same section
244
+ - Don't skip the OpenType stylistic sets β€” they define Roobert's character
245
+ - Don't use subtle hover effects β€” the rotation + hard shadow is the signature interaction
246
+ - Don't use small border radius (<12px) on feature cards β€” the generous rounding is structural
247
+ - Don't use standard shadows (blur-based) β€” Clay uses hard offset and multi-layer inset
248
+ - Don't forget the uppercase labels with 1.08px tracking β€” they're the wayfinding system
249
+
250
+ ## 8. Responsive Behavior
251
+
252
+ ### Breakpoints
253
+ | Name | Width | Key Changes |
254
+ |------|-------|-------------|
255
+ | Mobile Small | <479px | Single column, tight padding |
256
+ | Mobile | 479–767px | Standard mobile, stacked layout |
257
+ | Tablet | 768–991px | 2-column grids, condensed nav |
258
+ | Desktop | 992px+ | Full layout, 3-column grids, expanded sections |
259
+
260
+ ### Touch Targets
261
+ - Buttons: minimum 6.4px + 12.8px padding for adequate touch area
262
+ - Nav links: 15px font with generous spacing
263
+ - Mobile: full-width buttons for easy tapping
264
+
265
+ ### Collapsing Strategy
266
+ - Hero: 80px β†’ 60px β†’ smaller display text
267
+ - Navigation: horizontal β†’ hamburger at 767px
268
+ - Feature sections: multi-column β†’ stacked
269
+ - Colorful sections: maintain full-width but compress padding
270
+ - Card grids: 3-column β†’ 2-column β†’ single column
271
+
272
+ ### Image Behavior
273
+ - Product screenshots scale proportionally
274
+ - Colorful section illustrations adapt to viewport width
275
+ - Rounded corners maintained across breakpoints
276
+
277
+ ## 9. Agent Prompt Guide
278
+
279
+ ### Quick Color Reference
280
+ - Background: Warm Cream (`#faf9f7`)
281
+ - Text: Clay Black (`#000000`)
282
+ - Secondary text: Warm Silver (`#9f9b93`)
283
+ - Border: Oat Border (`#dad4c8`)
284
+ - Green accent: Matcha 600 (`#078a52`)
285
+ - Cyan accent: Slushie 500 (`#3bd3fd`)
286
+ - Gold accent: Lemon 500 (`#fbbd41`)
287
+ - Purple accent: Ube 800 (`#43089f`)
288
+ - Pink accent: Pomegranate 400 (`#fc7981`)
289
+
290
+ ### Example Component Prompts
291
+ - "Create a hero on warm cream (#faf9f7) background. Headline at 80px Roobert weight 600, line-height 1.00, letter-spacing -3.2px, OpenType 'ss01 ss03 ss10 ss11 ss12', black text. Subtitle at 20px weight 400, line-height 1.40, #9f9b93 text. Two buttons: white solid pill (12px radius) and ghost outlined (4px radius, 1px solid #717989)."
292
+ - "Design a colorful section with Matcha 800 (#02492a) background. Heading at 44px Roobert weight 600, letter-spacing -1.32px, white text. Body at 18px weight 400, line-height 1.60, #84e7a5 text. White card inset with oat border (#dad4c8), 24px radius."
293
+ - "Build a button with playful hover: default transparent background, black text, 16px Roobert weight 500. On hover: background #434346, text white, transform rotateZ(-8deg) translateY(-80%), hard shadow rgb(0,0,0) -7px 7px."
294
+ - "Create a card: white background, 1px solid #dad4c8 border, 24px radius. Shadow: rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px 1px inset. Title at 32px Roobert weight 600, letter-spacing -0.64px."
295
+ - "Design an uppercase label: 12px Roobert weight 600, text-transform uppercase, letter-spacing 1.08px, OpenType 'ss03 ss10 ss11 ss12'."
296
+
297
+ ### Iteration Guide
298
+ 1. Start with warm cream (#faf9f7) β€” never cool white
299
+ 2. Swatch colors are for full sections, not small accents β€” go bold with matcha, slushie, ube
300
+ 3. Oat borders (#dad4c8) everywhere β€” dashed variants for decoration
301
+ 4. OpenType stylistic sets are mandatory β€” they make Roobert look like Roobert
302
+ 5. Hover animations are the signature β€” rotation + hard shadow, not subtle fades
303
+ 6. Generous radius: 24px cards, 40px sections β€” nothing looks sharp or corporate
304
+ 7. Three weights: 600 (headings), 500 (UI), 400 (body) β€” strict roles
PRD.md ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PRD: Labas β€” AI-Powered Multi-Language Test Practice Platform
2
+
3
+ ## Problem Statement
4
+
5
+ Pelajar Indonesia yang mempersiapkan ujian bahasa (IELTS, TOEFL, JLPT, HSK, Goethe) kesulitan menemukan soal latihan reading comprehension yang:
6
+ - **Bervariasi** dan mencakup format soal spesifik tiap ujian (synonym, grammar-in-context, cloze, matching headings, author's view, dll.)
7
+ - **Terjangkau** β€” platform existing biasanya berbayar dan tidak fleksibel
8
+ - **Relevan** dengan kelemahan personal β€” tidak ada analisis per-section yang memberi rekomendasi perbaikan
9
+ - **Terbuka** β€” tidak ada platform open-source "bring your own key" untuk generasi soal AI
10
+
11
+ ## Solution
12
+
13
+ **Labas** adalah platform open-source yang menyediakan:
14
+ 1. **AI Question Generator** β€” generate soal reading comprehension pakai OpenAI-compatible API (BYOK). Dua mode: quick (single prompt) dan agentic (multi-step validation)
15
+ 2. **Question Bank** β€” kumpulan soal publik/privat yang bisa di-browse, di-rating, dan digunakan ulang
16
+ 3. **Package Builder** β€” susun paket soal dari kombinasi soal existing + AI-generated, dengan visibility publik/privat
17
+ 4. **Package Combiner** β€” gabung section dari berbagai paket tanpa perlu generate ulang
18
+ 5. **Exam Interface** β€” antarmuka ujian side-by-side (teks + soal), timer, auto-grade
19
+ 6. **Deep Analytics** β€” evaluasi per-section, identifikasi kelemahan, rekomendasi paket soal untuk perbaikan
20
+
21
+ Target pengguna: pelajar Indonesia yang mempersiapkan IELTS, TOEFL, JLPT, HSK, dan ujian bahasa Jerman.
22
+
23
+ ## User Stories
24
+
25
+ ### AI Generation
26
+ 1. Sebagai pelajar, saya ingin generate soal reading comprehension dengan AI menggunakan API key saya sendiri, agar saya bisa latihan tanpa batasan platform
27
+ 2. Sebagai pelajar, saya ingin memilih jenis ujian (IELTS/TOEFL/JLPT/HSK/German), section (Reading/Writing), dan tingkat kesulitan sebelum generate, agar soal sesuai kebutuhan saya
28
+ 3. Sebagai pelajar, saya ingin memilih format soal (multiple choice, true/false, matching, fill-in-blank, synonym, dll.) yang akan digenerate, agar latihan saya spesifik
29
+ 4. Sebagai pelajar, saya ingin memilih topik bacaan (Science, Business, Arts, dll.) untuk soal yang digenerate, agar konteks relevan dengan minat saya
30
+ 5. Sebagai pelajar, saya ingin melihat preview hasil generate sebelum menyimpan, agar saya bisa mengedit atau mere-generate soal yang kurang baik
31
+ 6. Sebagai power user, saya ingin menggunakan mode "agentic" yang melakukan validasi multi-step (passage β†’ validate β†’ questions β†’ self-answer β†’ score), agar kualitas soal lebih terjamin
32
+ 7. Sebagai pelajar casual, saya ingin menggunakan mode "quick" yang cepat dan hemat token, agar saya bisa langsung latihan
33
+
34
+ ### Question Bank
35
+ 8. Sebagai pelajar, saya ingin browse kumpulan soal publik berdasarkan bahasa, tingkat kesulitan, format, dan topik, agar saya bisa menemukan soal yang sesuai
36
+ 9. Sebagai pelajar, saya ingin melihat detail soal (passage, pertanyaan, pilihan, jawaban) sebelum memutuskan menggunakannya
37
+ 10. Sebagai pelajar, saya ingin memberi rating dan melihat rating soal/paket dari pengguna lain, agar saya tahu kualitasnya
38
+ 11. Sebagai kreator, saya ingin mempublikasikan soal yang saya buat ke bank publik, agar orang lain bisa menggunakannya
39
+ 12. Sebagai kreator, saya ingin menyimpan soal sebagai privat, agar hanya saya yang bisa mengaksesnya
40
+ 13. Sebagai pelajar, saya ingin mencari soal berdasarkan keyword di teks bacaan atau pertanyaan
41
+
42
+ ### Package Builder & Combiner
43
+ 14. Sebagai pelajar, saya ingin membuat paket soal (bundle) dari beberapa soal yang saya pilih dari bank, agar saya bisa latihan dalam satu sesi terstruktur
44
+ 15. Sebagai kreator, saya ingin memberi judul, deskripsi, dan visibility (publik/privat) pada paket yang saya buat
45
+ 16. Sebagai pelajar, saya ingin mengkombinasikan section dari berbagai paket (misal: Reading dari Paket A, Writing dari Paket B) menjadi satu paket ujian baru, tanpa perlu generate ulang dengan AI
46
+ 17. Sebagai pelajar, saya ingin melihat estimasi durasi dan jumlah soal sebelum memulai ujian
47
+
48
+ ### Exam Interface
49
+ 18. Sebagai pelajar, saya ingin melihat teks bacaan dan soal secara side-by-side saat mengerjakan, agar saya tidak perlu scroll bolak-balik
50
+ 19. Sebagai pelajar, saya ingin timer yang menunjukkan sisa waktu pengerjaan, agar saya bisa mengatur pace
51
+ 20. Sebagai pelajar, saya ingin navigasi antar soal (previous/next/jump), agar saya bisa mengerjakan dengan fleksibel
52
+ 21. Sebagai pelajar, saya ingin menandai soal untuk direview nanti, agar saya bisa fokus dulu ke soal lain
53
+ 22. Sebagai pelajar, saya ingin melihat progress (soal ke-X dari total) selama ujian
54
+ 23. Sebagai pelajar, saya ingin jawaban saya tersimpan otomatis, agar tidak hilang jika koneksi terputus
55
+ 24. Sebagai pelajar, saya ingin hasil ujian langsung terlihat setelah selesai (auto-grade untuk soal objektif)
56
+
57
+ ### Analytics & Evaluation
58
+ 25. Sebagai pelajar, saya ingin melihat skor per-section (Reading/Writing/Listening/Speaking) setelah ujian, agar saya tahu kekuatan dan kelemahan saya
59
+ 26. Sebagai pelajar, saya ingin melihat breakdown akurasi per skill tag (grammar, vocabulary, inference, dll.), agar saya tahu area mana yang perlu diperbaiki
60
+ 27. Sebagai pelajar, saya ingin melihat tren progres dari waktu ke waktu (line chart akurasi), agar saya tahu apakah saya improving
61
+ 28. Sebagai pelajar, saya ingin melihat radar chart proficiency per skill, agar visualisasi kelemahan saya jelas
62
+ 29. Sebagai pelajar, saya ingin mendapat rekomendasi paket soal yang fokus ke kelemahan saya, agar saya bisa memperbaiki area tersebut secara terarah
63
+ 30. Sebagai pelajar, saya ingin melihat statistik: total soal dikerjakan, total waktu latihan, akurasi rata-rata
64
+
65
+ ### Settings & Profile
66
+ 31. Sebagai pengguna, saya ingin menyimpan API key saya (OpenAI-compatible) dengan aman (encrypted), agar saya tidak perlu input ulang setiap generate
67
+ 32. Sebagai pengguna, saya ingin mengatur base URL dan model name untuk AI provider, agar saya bisa pakai provider alternatif (OpenRouter, Groq, local LLM)
68
+ 33. Sebagai pengguna, saya ingin mengelola (tambah/edit/hapus) multiple API keys untuk provider berbeda
69
+ 34. Sebagai pengguna, saya ingin mengedit profil (nama, avatar) saya
70
+
71
+ ### Community
72
+ 35. Sebagai pelajar, saya ingin melihat paket soal "trending" atau "staff pick", agar saya bisa menemukan soal berkualitas tinggi
73
+ 36. Sebagai kreator, saya ingin melihat berapa kali paket soal saya digunakan orang lain
74
+ 37. Sebagai pelajar, saya ingin membagikan link paket soal ke teman
75
+
76
+ ## Exam Types Supported
77
+
78
+ | Exam | Language | Levels | Focus Areas |
79
+ |------|----------|--------|-------------|
80
+ | IELTS Academic | English | Band 1-9 | Academic reading, formal writing |
81
+ | TOEFL iBT | English | 0-120 | Academic English, university context |
82
+ | JLPT | Japanese | N5-N1 | Kanji reading, grammar particles, text comprehension |
83
+ | HSK | Chinese | HSK 1-6 | Character recognition, word choice, sentence structure |
84
+ | Goethe / TestDaF | German | A1-C2 | Article/case, vocabulary in context, LΓΌckentext |
85
+
86
+ ## Question Formats
87
+
88
+ | Format | IELTS | TOEFL | JLPT | HSK | German | Auto-Gradeable |
89
+ |--------|-------|-------|------|-----|--------|:---:|
90
+ | Multiple Choice | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ |
91
+ | True/False/Not Given | βœ“ | βœ“ | | | βœ“ | βœ“ |
92
+ | Matching Headings | βœ“ | βœ“ | | | βœ“ | βœ“ |
93
+ | Matching Information | βœ“ | βœ“ | | | βœ“ | βœ“ |
94
+ | Fill in Blank | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ |
95
+ | Synonym / Vocabulary | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ |
96
+ | Grammar in Context | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ |
97
+ | Sentence Completion | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ |
98
+ | Summary Completion | βœ“ | βœ“ | | | | βœ“ |
99
+ | Cloze Test | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ |
100
+ | Reference (pronoun) | | βœ“ | βœ“ | | | βœ“ |
101
+ | Author's View | βœ“ | βœ“ | βœ“ | | | βœ“ |
102
+ | Kanji Reading | | | βœ“ | | | βœ“ |
103
+ | Particle Choice | | | βœ“ | | | βœ“ |
104
+ | Article / Case Choice | | | | | βœ“ | βœ“ |
105
+
106
+ ## Implementation Decisions
107
+
108
+ ### Tech Stack
109
+ - **Frontend**: React 19 + TanStack Router (SPA) + TanStack Query + Tailwind CSS 4 + shadcn/ui
110
+ - **Backend**: Hono + tRPC
111
+ - **Auth**: Better Auth (email/password)
112
+ - **Database**: PostgreSQL + Drizzle ORM
113
+ - **AI**: OpenAI-compatible API (generic interface, user-provided keys)
114
+ - **Monorepo**: Turborepo + Bun workspaces
115
+ - **Theme**: Light default, dark toggle (system preference aware via next-themes)
116
+
117
+ ### Architecture
118
+
119
+ ```
120
+ apps/web/ React SPA frontend
121
+ routes/ File-based routes (TanStack Router)
122
+ index.tsx Landing / Home
123
+ generate.tsx AI Generator wizard
124
+ bank.tsx Question Bank browse
125
+ bank/$id.tsx Package / Question detail
126
+ builder.tsx Test Builder (craft package)
127
+ builder/combo.tsx Package Combiner
128
+ exam/$id.tsx Exam Interface
129
+ exam/$id/result.tsx Exam Results
130
+ analytics.tsx Deep Analytics dashboard
131
+ settings.tsx Settings (API keys, profile)
132
+
133
+ apps/server/ Hono backend
134
+ Mounts Better Auth + tRPC
135
+
136
+ packages/
137
+ ai/ AI generation pipeline (new)
138
+ api/ tRPC routers + context
139
+ routers/
140
+ ai.ts generateQuestions (streaming)
141
+ question.ts CRUD questions, search, filter
142
+ package.ts CRUD packages, sections
143
+ combo.ts Combo package creation
144
+ exam.ts Attempts, grading, results
145
+ analytics.ts Stats, trends, recommendations
146
+ settings.ts API key management
147
+ auth/ Better Auth config
148
+ db/ Drizzle ORM schema
149
+ ui/ shadcn/ui shared components
150
+ env/ Environment validation
151
+ ```
152
+
153
+ ### Deep Modules (testable in isolation)
154
+
155
+ - **`packages/ai/`** β€” AI generation pipeline
156
+ - Interface: `generateQuestions(input: GenerationInput) => GenerationResult`
157
+ - Two pipelines: `quick` (single call) and `agentic` (multi-step)
158
+ - Zod schemas for all question formats (type-safe generation + validation)
159
+ - OpenAI-compatible client abstraction
160
+
161
+ - **`packages/api/src/routers/`** β€” tRPC routers
162
+ - Each router encapsulates one domain with clear procedures
163
+
164
+ ### Database Schema (New Tables)
165
+
166
+ ```
167
+ exam_type id, name, language, description
168
+ section_type id, name
169
+ question id, exam_type_id, section_type_id, format, passage_text,
170
+ question_text, options_json, correct_answer, explanation,
171
+ difficulty (1-5), skill_tags[], source (ai|manual),
172
+ ai_model, creator_user_id, is_public, usage_count, avg_rating
173
+ package id, title, description, exam_type_id, creator_user_id,
174
+ is_public, total_questions, usage_count, avg_rating
175
+ package_section id, package_id, section_type_id, title, order_index
176
+ section_question id, section_id, question_id, order_index
177
+ combo_package id, title, description, creator_user_id, is_public
178
+ combo_section id, combo_id, source_package_id, source_section_id, order_index
179
+ test_attempt id, user_id, package_id_or_combo_id, started_at, finished_at, total_score
180
+ section_result id, attempt_id, section_type, score, max_score, time_spent_sec
181
+ answer id, section_result_id, question_id, user_answer, is_correct, time_spent_sec
182
+ user_api_key id, user_id, provider, base_url, api_key_encrypted, model_name, is_active
183
+ question_rating id, user_id, question_id, score (1-5), created_at
184
+ ```
185
+
186
+ ### Agentic AI Pipeline
187
+
188
+ ```
189
+ Step 1: Passage Generator β†’ Authentic reading passage sesuai level ujian
190
+ Step 2: Passage Validator β†’ Validasi akurasi, difficulty, grammar, panjang
191
+ Step 3: Question Generator β†’ Generate N questions dalam format yang diminta
192
+ Step 4: Self-Answer & Validate β†’ AI menjawab sendiri, flag ambigu, hitung confidence
193
+ Step 5: Quality Scorer β†’ Overall score, needsHumanReview flag
194
+ ```
195
+
196
+ ### Weakness Analysis Algorithm
197
+
198
+ 1. Group answers by `section_type` dan `skill_tag`
199
+ 2. Hitung akurasi per group
200
+ 3. Identifikasi skill tags dengan akurasi di bawah threshold (default 70%)
201
+ 4. Sort by `total_attempts * (1 - accuracy)` β€” prioritize yang sering salah
202
+ 5. Cari paket soal publik dengan skill tags yang match kelemahan user
203
+ 6. Return top-N rekomendasi paket
204
+
205
+ ---
206
+
207
+ ## Implementation Phases
208
+
209
+ ### Phase 1 β€” Foundation (Schema + Core Infrastructure)
210
+ **Goal:** Database siap, user bisa manage API key, AI pipeline dasar berjalan
211
+
212
+ | # | Task | Package |
213
+ |---|------|---------|
214
+ | 1.1 | Add application tables to Drizzle schema | `packages/db/` |
215
+ | 1.2 | Generate & run migrations | `packages/db/` |
216
+ | 1.3 | Create `packages/ai/` β€” OpenAI-compatible client + Zod schemas for all question formats | `packages/ai/` |
217
+ | 1.4 | AI key management tRPC router (encrypt/decrypt) | `packages/api/` |
218
+ | 1.5 | Settings page β†’ API key CRUD UI | `apps/web/` |
219
+ | 1.6 | Quick mode AI generation pipeline (single call) | `packages/ai/` |
220
+ | 1.7 | AI Generator router + basic UI page | `packages/api/` + `apps/web/` |
221
+
222
+ **User stories delivered:** 1, 2, 3, 4, 7, 31, 32, 33
223
+
224
+ ### Phase 2 β€” Question Bank & Packages
225
+ **Goal:** Soal bisa disimpan, dipublikasikan, di-browse, dan dibundle
226
+
227
+ | # | Task | Package |
228
+ |---|------|---------|
229
+ | 2.1 | Question CRUD tRPC router (search, filter, publish/unpublish) | `packages/api/` |
230
+ | 2.2 | Package CRUD tRPC router (create, add sections, add questions) | `packages/api/` |
231
+ | 2.3 | Question Bank browse page (grid, search, filters) | `apps/web/` |
232
+ | 2.4 | Package detail page (preview soal, rating, stats) | `apps/web/` |
233
+ | 2.5 | AI Generator β†’ save result as question / package | `apps/web/` |
234
+ | 2.6 | Test Builder page (craft package from bank items) | `apps/web/` |
235
+ | 2.7 | Rating system for public questions/packages | `packages/api/` |
236
+
237
+ **User stories delivered:** 5, 8, 9, 10, 11, 12, 13, 14, 15, 17
238
+
239
+ ### Phase 3 β€” Package Combiner + Agentic Mode
240
+ **Goal:** Pengguna bisa mix section dari berbagai paket; power user bisa pakai agentic generation
241
+
242
+ | # | Task | Package |
243
+ |---|------|---------|
244
+ | 3.1 | Combo package tRPC router | `packages/api/` |
245
+ | 3.2 | Package Combiner UI (select sections from different packages) | `apps/web/` |
246
+ | 3.3 | Agentic mode AI pipeline (multi-step) | `packages/ai/` |
247
+ | 3.4 | Agentic mode toggle + streaming progress in AI Generator UI | `apps/web/` |
248
+
249
+ **User stories delivered:** 6, 16
250
+
251
+ ### Phase 4 β€” Exam Experience
252
+ **Goal:** Pengguna bisa mengerjakan ujian dengan antarmuka side-by-side, timer, auto-grade
253
+
254
+ | # | Task | Package |
255
+ |---|------|---------|
256
+ | 4.1 | Exam attempt + answer submission tRPC router | `packages/api/` |
257
+ | 4.2 | Auto-grade logic (untuk semua format objektif) | `packages/api/` |
258
+ | 4.3 | Exam Interface page (side-by-side passage + questions) | `apps/web/` |
259
+ | 4.4 | Timer component (client-side with periodic sync) | `apps/web/` |
260
+ | 4.5 | Question navigation (prev/next/jump, mark for review) | `apps/web/` |
261
+ | 4.6 | Auto-save answers | `apps/web/` |
262
+ | 4.7 | Exam Results page (per-section scores, answer review) | `apps/web/` |
263
+
264
+ **User stories delivered:** 18, 19, 20, 21, 22, 23, 24, 25
265
+
266
+ ### Phase 5 β€” Analytics & Recommendations
267
+ **Goal:** User bisa melihat performa, kelemahan, dan rekomendasi perbaikan
268
+
269
+ | # | Task | Package |
270
+ |---|------|---------|
271
+ | 5.1 | Analytics tRPC router (stats, trends, skill breakdown) | `packages/api/` |
272
+ | 5.2 | Weakness identification + recommendation algorithm | `packages/api/` |
273
+ | 5.3 | Deep Analytics dashboard page (stats cards, line chart, radar chart) | `apps/web/` |
274
+ | 5.4 | Performance by Skill table | `apps/web/` |
275
+ | 5.5 | Recommended packages section on dashboard | `apps/web/` |
276
+
277
+ **User stories delivered:** 26, 27, 28, 29, 30
278
+
279
+ ### Phase 6 β€” Community & Polish
280
+ **Goal:** Platform terasa hidup dengan konten komunitas, trending, PWA offline support
281
+
282
+ | # | Task | Package |
283
+ |---|------|---------|
284
+ | 6.1 | Trending / most-used packages endpoint | `packages/api/` |
285
+ | 6.2 | Staff pick / featured content | `apps/web/` |
286
+ | 6.3 | Creator stats (usage count, rating) on profile | `apps/web/` |
287
+ | 6.4 | Share link for public packages | `apps/web/` |
288
+ | 6.5 | PWA offline support for exam mode (cache questions) | `apps/web/` |
289
+ | 6.6 | UI polish, loading skeletons, empty states, error boundaries | `apps/web/` |
290
+ | 6.7 | i18n foundation (Bahasa Indonesia UI strings) | `apps/web/` |
291
+
292
+ **User stories delivered:** 34, 35, 36, 37
293
+
294
+ ---
295
+
296
+ ## Out of Scope
297
+
298
+ - Mobile app (React Native) β€” web-first, PWA covers mobile
299
+ - Writing essay auto-grading β€” terlalu kompleks dan unreliable untuk AI saat ini
300
+ - Speaking section generation β€” fokus ke reading comprehension dulu
301
+ - Listening audio generation β€” perlu TTS, terlalu kompleks untuk initial release
302
+ - Proctoring / anti-cheat
303
+ - Subscription / payment system β€” open-source, self-hosted
304
+ - Real-time multiplayer
305
+ - Social features (comments, following) beyond ratings
306
+ - Admin dashboard β€” semua management via DB langsung atau CLI
307
+ - Email verification & password reset flow (bisa ditambah nanti)
308
+ - OAuth login (Google, GitHub) β€” email/password saja untuk initial release
309
+
310
+ ## Testing Strategy
311
+
312
+ - **AI package** (`packages/ai/`): Unit tests for Zod validation schemas, integration tests with mocked LLM responses
313
+ - **API routers** (`packages/api/src/routers/`): tRPC procedure tests against test database
314
+ - **Package Combiner**: Complex business logic β€” priority for testing
315
+ - **Auto-grade logic**: Must be correct β€” parametrized tests per format
316
+ - **Test structure**: `packages/*/__tests__/` (establishing fresh β€” codebase has no tests yet)
317
+
318
+ A good test:
319
+ - Tests external behavior (API contract), not implementation details
320
+ - Uses tRPC test client to call procedures end-to-end
321
+ - Isolates AI calls via mocking
322
+
323
+ ## Further Notes
324
+
325
+ - Repo akan dijadikan open-source β€” semua API key di-skip dari git, dokumentasi jelas untuk self-hosting
326
+ - UI text dalam Bahasa Indonesia (target pengguna), kode dalam English
327
+ - Soal bisa bilingual (passage dalam bahasa target, instruksi dalam Bahasa Indonesia)
328
+ - Timer di exam interface harus client-side dengan periodic sync ke server
329
+ - PWA support sudah ada, perlu di-enhance untuk offline exam mode (cache soal)
330
+ - Default theme: light, dark toggle tetap ada
331
+ - Nama "Labas" dari "labs" + "bahasa"
apps/web/package.json CHANGED
@@ -13,6 +13,7 @@
13
  },
14
  "dependencies": {
15
  "@hookform/resolvers": "^5.2.2",
 
16
  "@labas/api": "workspace:*",
17
  "@labas/auth": "workspace:*",
18
  "@labas/env": "workspace:*",
@@ -26,6 +27,7 @@
26
  "@trpc/tanstack-react-query": "^11.16.0",
27
  "better-auth": "catalog:",
28
  "dotenv": "catalog:",
 
29
  "lucide-react": "^1.8.0",
30
  "next-themes": "catalog:",
31
  "react": "^19.2.5",
 
13
  },
14
  "dependencies": {
15
  "@hookform/resolvers": "^5.2.2",
16
+ "@labas/ai": "workspace:*",
17
  "@labas/api": "workspace:*",
18
  "@labas/auth": "workspace:*",
19
  "@labas/env": "workspace:*",
 
27
  "@trpc/tanstack-react-query": "^11.16.0",
28
  "better-auth": "catalog:",
29
  "dotenv": "catalog:",
30
+ "flag-icons": "^7.5.0",
31
  "lucide-react": "^1.8.0",
32
  "next-themes": "catalog:",
33
  "react": "^19.2.5",
apps/web/src/components/header.tsx CHANGED
@@ -1,32 +1,17 @@
1
  import { Link } from "@tanstack/react-router";
2
-
3
  import { ModeToggle } from "./mode-toggle";
4
  import UserMenu from "./user-menu";
5
 
6
  export default function Header() {
7
- const links = [
8
- { to: "/", label: "Home" },
9
- { to: "/dashboard", label: "Dashboard" },
10
- ] as const;
11
-
12
  return (
13
- <div>
14
- <div className="flex flex-row items-center justify-between px-2 py-1">
15
- <nav className="flex gap-4 text-lg">
16
- {links.map(({ to, label }) => {
17
- return (
18
- <Link key={to} to={to}>
19
- {label}
20
- </Link>
21
- );
22
- })}
23
- </nav>
24
- <div className="flex items-center gap-2">
25
- <ModeToggle />
26
- <UserMenu />
27
- </div>
28
  </div>
29
- <hr />
30
- </div>
31
  );
32
  }
 
1
  import { Link } from "@tanstack/react-router";
 
2
  import { ModeToggle } from "./mode-toggle";
3
  import UserMenu from "./user-menu";
4
 
5
  export default function Header() {
 
 
 
 
 
6
  return (
7
+ <header className="sticky top-0 z-50 flex items-center justify-between w-full px-6 py-3 bg-surface-container-low border-b border-outline-variant/50">
8
+ <div className="flex items-center gap-6">
9
+ <span className="text-xl font-bold tracking-tight text-primary font-headline md:hidden">Labas</span>
10
+ </div>
11
+ <div className="flex items-center gap-4">
12
+ <ModeToggle />
13
+ <UserMenu />
 
 
 
 
 
 
 
 
14
  </div>
15
+ </header>
 
16
  );
17
  }
apps/web/src/components/sidebar.tsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link, useLocation } from "@tanstack/react-router";
2
+ import { authClient } from "@/lib/auth-client";
3
+ import { useSidebar } from "@/hooks/use-sidebar";
4
+
5
+ const navItems = [
6
+ { to: "/", label: "Dashboard", icon: "dashboard" },
7
+ { to: "/generate", label: "AI Lab", icon: "auto_awesome" },
8
+ { to: "/bank", label: "Bank Soal", icon: "database" },
9
+ { to: "/analytics", label: "Analytics", icon: "analytics" },
10
+ ];
11
+
12
+ const bottomItems = [
13
+ { to: "/settings", label: "Settings", icon: "settings" },
14
+ ];
15
+
16
+ function NavIcon({ name }: { name: string }) {
17
+ return <span className="material-symbols-outlined text-xl w-6 text-center">{name}</span>;
18
+ }
19
+
20
+ export function Sidebar() {
21
+ const location = useLocation();
22
+ const { collapsed, toggle } = useSidebar();
23
+ const { data: session } = authClient.useSession();
24
+ const isLoggedIn = !!session;
25
+
26
+ return (
27
+ <>
28
+ {/* Desktop Sidebar */}
29
+ <aside
30
+ className={`fixed left-0 top-0 h-full flex flex-col z-40 bg-[var(--warm-cream)] border-r border-[var(--oat-border)] hidden md:flex transition-all duration-300 ${
31
+ collapsed ? "w-16 items-center px-2 py-4" : "w-64 p-4"
32
+ }`}
33
+ >
34
+ <div className={`mb-8 ${collapsed ? "px-0 text-center" : "px-4"}`}>
35
+ <h1 className="text-xl font-extrabold text-[var(--clay-black)] font-headline tracking-tight">
36
+ {collapsed ? "L" : "Labas"}
37
+ </h1>
38
+ {!collapsed && (
39
+ <p className="text-xs text-[var(--warm-charcoal)] uppercase tracking-widest font-semibold mt-1">
40
+ Exam Prep Portal
41
+ </p>
42
+ )}
43
+ </div>
44
+
45
+ <nav className="flex-1 space-y-1 w-full">
46
+ {navItems.map((item) => {
47
+ const isActive = location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
48
+ return (
49
+ <Link
50
+ key={item.to}
51
+ to={item.to}
52
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all group clay-hover ${
53
+ isActive
54
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
55
+ : "text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)]"
56
+ } ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4"}`}
57
+ title={collapsed ? item.label : undefined}
58
+ >
59
+ <NavIcon name={item.icon} />
60
+ {!collapsed && <span className="text-sm font-medium">{item.label}</span>}
61
+ </Link>
62
+ );
63
+ })}
64
+ </nav>
65
+
66
+ <div className="mt-auto space-y-1 pt-4 border-t border-[var(--oat-border)] w-full">
67
+ {bottomItems.map((item) => {
68
+ const isActive = location.pathname === item.to;
69
+ return (
70
+ <Link
71
+ key={item.to}
72
+ to={item.to}
73
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover ${
74
+ isActive
75
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
76
+ : "text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)]"
77
+ } ${collapsed ? "justify-center py-3 px-2" : "py-3 px-4"}`}
78
+ title={collapsed ? item.label : undefined}
79
+ >
80
+ <NavIcon name={item.icon} />
81
+ {!collapsed && <span className="text-sm font-medium">{item.label}</span>}
82
+ </Link>
83
+ );
84
+ })}
85
+ {isLoggedIn ? (
86
+ <button
87
+ onClick={() => authClient.signOut()}
88
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
89
+ collapsed ? "justify-center py-3 px-2" : "py-3 px-4 text-left"
90
+ }`}
91
+ title={collapsed ? "Keluar" : undefined}
92
+ >
93
+ <NavIcon name="logout" />
94
+ {!collapsed && <span className="text-sm font-medium">Keluar</span>}
95
+ </button>
96
+ ) : (
97
+ <Link
98
+ to="/login"
99
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] ${
100
+ collapsed ? "justify-center py-3 px-2" : "py-3 px-4"
101
+ }`}
102
+ title={collapsed ? "Masuk" : undefined}
103
+ >
104
+ <NavIcon name="login" />
105
+ {!collapsed && <span className="text-sm font-medium">Masuk</span>}
106
+ </Link>
107
+ )}
108
+ </div>
109
+ </aside>
110
+
111
+ {/* Floating toggle button (right edge of sidebar) */}
112
+ <button
113
+ onClick={toggle}
114
+ className="hidden md:flex absolute top-6 z-50 items-center justify-center w-10 h-10 rounded-full bg-[var(--pure-white)] border-2 border-[var(--oat-border)] shadow-md hover:bg-[var(--oat-light)] transition-colors text-[var(--warm-charcoal)] clay-hover"
115
+ title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
116
+ style={{ left: collapsed ? "16px" : "256px" }}
117
+ >
118
+ <span className="material-symbols-outlined text-lg">menu_open</span>
119
+ </button>
120
+
121
+ {/* Mobile bottom nav */}
122
+ <nav className="md:hidden fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-4 pb-6 pt-3 bg-[var(--warm-cream)]/90 backdrop-blur-xl border-t border-[var(--oat-border)] rounded-t-[var(--radius-xl)]">
123
+ {navItems.slice(0, 4).map((item) => {
124
+ const isActive = location.pathname === item.to;
125
+ return (
126
+ <Link
127
+ key={item.to}
128
+ to={item.to}
129
+ className={`flex flex-col items-center justify-center px-4 py-1.5 transition-all rounded-[var(--radius-lg)] ${
130
+ isActive
131
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
132
+ : "text-[var(--warm-charcoal)]"
133
+ }`}
134
+ >
135
+ <NavIcon name={item.icon} />
136
+ <span className="text-[10px] font-medium uppercase tracking-wider mt-1">{item.label}</span>
137
+ </Link>
138
+ );
139
+ })}
140
+ </nav>
141
+ </>
142
+ );
143
+ }
apps/web/src/components/sign-in-form.tsx CHANGED
@@ -30,7 +30,7 @@ export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: ()
30
  {
31
  onSuccess: () => {
32
  navigate({
33
- to: "/dashboard",
34
  });
35
  toast.success("Sign in successful");
36
  },
 
30
  {
31
  onSuccess: () => {
32
  navigate({
33
+ to: "/",
34
  });
35
  toast.success("Sign in successful");
36
  },
apps/web/src/components/sign-up-form.tsx CHANGED
@@ -32,7 +32,7 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
32
  {
33
  onSuccess: () => {
34
  navigate({
35
- to: "/dashboard",
36
  });
37
  toast.success("Sign up successful");
38
  },
 
32
  {
33
  onSuccess: () => {
34
  navigate({
35
+ to: "/",
36
  });
37
  toast.success("Sign up successful");
38
  },
apps/web/src/hooks/use-api-key.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { encryptText, decryptText } from "@/lib/crypto";
3
+
4
+ const STORAGE_KEY = "labas_api_key";
5
+
6
+ export interface StoredApiKey {
7
+ provider: string;
8
+ baseUrl: string;
9
+ apiKey: string;
10
+ modelName: string;
11
+ }
12
+
13
+ export function useApiKey() {
14
+ const [storedKey, setStoredKey] = useState<StoredApiKey | null>(null);
15
+ const [isLoading, setIsLoading] = useState(true);
16
+
17
+ useEffect(() => {
18
+ loadKey();
19
+ }, []);
20
+
21
+ const loadKey = async () => {
22
+ try {
23
+ const encrypted = localStorage.getItem(STORAGE_KEY);
24
+ if (encrypted) {
25
+ const decrypted = await decryptText(encrypted);
26
+ setStoredKey(JSON.parse(decrypted));
27
+ }
28
+ } catch {
29
+ // Invalid or corrupted data
30
+ localStorage.removeItem(STORAGE_KEY);
31
+ } finally {
32
+ setIsLoading(false);
33
+ }
34
+ };
35
+
36
+ const saveKey = useCallback(async (key: StoredApiKey) => {
37
+ const encrypted = await encryptText(JSON.stringify(key));
38
+ localStorage.setItem(STORAGE_KEY, encrypted);
39
+ setStoredKey(key);
40
+ }, []);
41
+
42
+ const removeKey = useCallback(() => {
43
+ localStorage.removeItem(STORAGE_KEY);
44
+ setStoredKey(null);
45
+ }, []);
46
+
47
+ return {
48
+ storedKey,
49
+ isLoading,
50
+ saveKey,
51
+ removeKey,
52
+ hasKey: !!storedKey,
53
+ };
54
+ }
apps/web/src/hooks/use-sidebar.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+
3
+ const STORAGE_KEY = "labas_sidebar_collapsed";
4
+
5
+ export function useSidebar() {
6
+ const [collapsed, setCollapsed] = useState(() => {
7
+ if (typeof window === "undefined") return false;
8
+ return localStorage.getItem(STORAGE_KEY) === "true";
9
+ });
10
+
11
+ useEffect(() => {
12
+ localStorage.setItem(STORAGE_KEY, String(collapsed));
13
+ }, [collapsed]);
14
+
15
+ const toggle = () => setCollapsed((prev) => !prev);
16
+
17
+ return { collapsed, toggle };
18
+ }
apps/web/src/lib/crypto.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DB_NAME = "labas-crypto";
2
+ const STORE_NAME = "keys";
3
+ const KEY_NAME = "labas-master-key";
4
+
5
+ function openDB(): Promise<IDBDatabase> {
6
+ return new Promise((resolve, reject) => {
7
+ const request = indexedDB.open(DB_NAME, 1);
8
+ request.onerror = () => reject(request.error);
9
+ request.onsuccess = () => resolve(request.result);
10
+ request.onupgradeneeded = () => {
11
+ const db = request.result;
12
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
13
+ db.createObjectStore(STORE_NAME);
14
+ }
15
+ };
16
+ });
17
+ }
18
+
19
+ async function getFromStore(db: IDBDatabase, key: string): Promise<Uint8Array | undefined> {
20
+ return new Promise((resolve, reject) => {
21
+ const tx = db.transaction(STORE_NAME, "readonly");
22
+ const store = tx.objectStore(STORE_NAME);
23
+ const request = store.get(key);
24
+ request.onsuccess = () => resolve(request.result);
25
+ request.onerror = () => reject(request.error);
26
+ });
27
+ }
28
+
29
+ async function putToStore(db: IDBDatabase, key: string, value: Uint8Array): Promise<void> {
30
+ return new Promise((resolve, reject) => {
31
+ const tx = db.transaction(STORE_NAME, "readwrite");
32
+ const store = tx.objectStore(STORE_NAME);
33
+ const request = store.put(value, key);
34
+ request.onsuccess = () => resolve();
35
+ request.onerror = () => reject(request.error);
36
+ });
37
+ }
38
+
39
+ async function getOrCreateMasterKey(): Promise<CryptoKey> {
40
+ const db = await openDB();
41
+ const stored = await getFromStore(db, KEY_NAME);
42
+
43
+ if (stored) {
44
+ return crypto.subtle.importKey(
45
+ "raw",
46
+ stored.buffer as ArrayBuffer,
47
+ { name: "AES-GCM" },
48
+ false,
49
+ ["encrypt", "decrypt"],
50
+ );
51
+ }
52
+
53
+ const key = await crypto.subtle.generateKey(
54
+ { name: "AES-GCM", length: 256 },
55
+ true,
56
+ ["encrypt", "decrypt"],
57
+ );
58
+
59
+ const exported = await crypto.subtle.exportKey("raw", key);
60
+ await putToStore(db, KEY_NAME, new Uint8Array(exported));
61
+
62
+ return key;
63
+ }
64
+
65
+ interface EncryptedData {
66
+ iv: string;
67
+ data: string;
68
+ }
69
+
70
+ export async function encryptText(plaintext: string): Promise<string> {
71
+ const key = await getOrCreateMasterKey();
72
+ const iv = crypto.getRandomValues(new Uint8Array(12));
73
+ const encoded = new TextEncoder().encode(plaintext);
74
+
75
+ const ciphertext = await crypto.subtle.encrypt(
76
+ { name: "AES-GCM", iv },
77
+ key,
78
+ encoded,
79
+ );
80
+
81
+ const combined: EncryptedData = {
82
+ iv: arrayBufferToBase64(iv),
83
+ data: arrayBufferToBase64(ciphertext),
84
+ };
85
+
86
+ return JSON.stringify(combined);
87
+ }
88
+
89
+ export async function decryptText(encrypted: string): Promise<string> {
90
+ const key = await getOrCreateMasterKey();
91
+ const parsed: EncryptedData = JSON.parse(encrypted);
92
+ const iv = base64ToArrayBuffer(parsed.iv);
93
+ const ciphertext = base64ToArrayBuffer(parsed.data);
94
+
95
+ const decrypted = await crypto.subtle.decrypt(
96
+ { name: "AES-GCM", iv },
97
+ key,
98
+ ciphertext,
99
+ );
100
+
101
+ return new TextDecoder().decode(decrypted);
102
+ }
103
+
104
+ function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
105
+ const bytes = new Uint8Array(buffer);
106
+ let binary = "";
107
+ for (let i = 0; i < bytes.byteLength; i++) {
108
+ binary += String.fromCharCode(bytes[i]);
109
+ }
110
+ return btoa(binary);
111
+ }
112
+
113
+ function base64ToArrayBuffer(base64: string): ArrayBuffer {
114
+ const binary = atob(base64);
115
+ const bytes = new Uint8Array(binary.length);
116
+ for (let i = 0; i < binary.length; i++) {
117
+ bytes[i] = binary.charCodeAt(i);
118
+ }
119
+ return bytes.buffer;
120
+ }
apps/web/src/routeTree.gen.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as SettingsRouteImport } from './routes/settings'
13
+ import { Route as LoginRouteImport } from './routes/login'
14
+ import { Route as GenerateRouteImport } from './routes/generate'
15
+ import { Route as IndexRouteImport } from './routes/index'
16
+
17
+ const SettingsRoute = SettingsRouteImport.update({
18
+ id: '/settings',
19
+ path: '/settings',
20
+ getParentRoute: () => rootRouteImport,
21
+ } as any)
22
+ const LoginRoute = LoginRouteImport.update({
23
+ id: '/login',
24
+ path: '/login',
25
+ getParentRoute: () => rootRouteImport,
26
+ } as any)
27
+ const GenerateRoute = GenerateRouteImport.update({
28
+ id: '/generate',
29
+ path: '/generate',
30
+ getParentRoute: () => rootRouteImport,
31
+ } as any)
32
+ const IndexRoute = IndexRouteImport.update({
33
+ id: '/',
34
+ path: '/',
35
+ getParentRoute: () => rootRouteImport,
36
+ } as any)
37
+
38
+ export interface FileRoutesByFullPath {
39
+ '/': typeof IndexRoute
40
+ '/generate': typeof GenerateRoute
41
+ '/login': typeof LoginRoute
42
+ '/settings': typeof SettingsRoute
43
+ }
44
+ export interface FileRoutesByTo {
45
+ '/': typeof IndexRoute
46
+ '/generate': typeof GenerateRoute
47
+ '/login': typeof LoginRoute
48
+ '/settings': typeof SettingsRoute
49
+ }
50
+ export interface FileRoutesById {
51
+ __root__: typeof rootRouteImport
52
+ '/': typeof IndexRoute
53
+ '/generate': typeof GenerateRoute
54
+ '/login': typeof LoginRoute
55
+ '/settings': typeof SettingsRoute
56
+ }
57
+ export interface FileRouteTypes {
58
+ fileRoutesByFullPath: FileRoutesByFullPath
59
+ fullPaths: '/' | '/generate' | '/login' | '/settings'
60
+ fileRoutesByTo: FileRoutesByTo
61
+ to: '/' | '/generate' | '/login' | '/settings'
62
+ id: '__root__' | '/' | '/generate' | '/login' | '/settings'
63
+ fileRoutesById: FileRoutesById
64
+ }
65
+ export interface RootRouteChildren {
66
+ IndexRoute: typeof IndexRoute
67
+ GenerateRoute: typeof GenerateRoute
68
+ LoginRoute: typeof LoginRoute
69
+ SettingsRoute: typeof SettingsRoute
70
+ }
71
+
72
+ declare module '@tanstack/react-router' {
73
+ interface FileRoutesByPath {
74
+ '/settings': {
75
+ id: '/settings'
76
+ path: '/settings'
77
+ fullPath: '/settings'
78
+ preLoaderRoute: typeof SettingsRouteImport
79
+ parentRoute: typeof rootRouteImport
80
+ }
81
+ '/login': {
82
+ id: '/login'
83
+ path: '/login'
84
+ fullPath: '/login'
85
+ preLoaderRoute: typeof LoginRouteImport
86
+ parentRoute: typeof rootRouteImport
87
+ }
88
+ '/generate': {
89
+ id: '/generate'
90
+ path: '/generate'
91
+ fullPath: '/generate'
92
+ preLoaderRoute: typeof GenerateRouteImport
93
+ parentRoute: typeof rootRouteImport
94
+ }
95
+ '/': {
96
+ id: '/'
97
+ path: '/'
98
+ fullPath: '/'
99
+ preLoaderRoute: typeof IndexRouteImport
100
+ parentRoute: typeof rootRouteImport
101
+ }
102
+ }
103
+ }
104
+
105
+ const rootRouteChildren: RootRouteChildren = {
106
+ IndexRoute: IndexRoute,
107
+ GenerateRoute: GenerateRoute,
108
+ LoginRoute: LoginRoute,
109
+ SettingsRoute: SettingsRoute,
110
+ }
111
+ export const routeTree = rootRouteImport
112
+ ._addFileChildren(rootRouteChildren)
113
+ ._addFileTypes<FileRouteTypes>()
apps/web/src/routes/__root.tsx CHANGED
@@ -4,8 +4,9 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
4
  import { HeadContent, Outlet, createRootRouteWithContext } from "@tanstack/react-router";
5
  import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
6
 
7
- import Header from "@/components/header";
8
  import { ThemeProvider } from "@/components/theme-provider";
 
9
  import type { trpc } from "@/utils/trpc";
10
 
11
  import "../index.css";
@@ -19,36 +20,34 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
19
  component: RootComponent,
20
  head: () => ({
21
  meta: [
22
- {
23
- title: "labas",
24
- },
25
- {
26
- name: "description",
27
- content: "labas is a web application",
28
- },
29
  ],
30
  links: [
31
- {
32
- rel: "icon",
33
- href: "/favicon.ico",
34
- },
35
  ],
36
  }),
37
  });
38
 
39
  function RootComponent() {
 
 
40
  return (
41
  <>
42
  <HeadContent />
43
  <ThemeProvider
44
  attribute="class"
45
- defaultTheme="dark"
 
46
  disableTransitionOnChange
47
- storageKey="vite-ui-theme"
48
  >
49
- <div className="grid grid-rows-[auto_1fr] h-svh">
50
- <Header />
51
- <Outlet />
 
 
52
  </div>
53
  <Toaster richColors />
54
  </ThemeProvider>
 
4
  import { HeadContent, Outlet, createRootRouteWithContext } from "@tanstack/react-router";
5
  import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
6
 
7
+ import { Sidebar } from "@/components/sidebar";
8
  import { ThemeProvider } from "@/components/theme-provider";
9
+ import { useSidebar } from "@/hooks/use-sidebar";
10
  import type { trpc } from "@/utils/trpc";
11
 
12
  import "../index.css";
 
20
  component: RootComponent,
21
  head: () => ({
22
  meta: [
23
+ { title: "Labas β€” AI Exam Practice" },
24
+ { name: "description", content: "AI-powered multi-language test practice platform" },
 
 
 
 
 
25
  ],
26
  links: [
27
+ { rel: "icon", href: "/favicon.ico" },
28
+ { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" },
 
 
29
  ],
30
  }),
31
  });
32
 
33
  function RootComponent() {
34
+ const { collapsed } = useSidebar();
35
+
36
  return (
37
  <>
38
  <HeadContent />
39
  <ThemeProvider
40
  attribute="class"
41
+ defaultTheme="light"
42
+ enableSystem
43
  disableTransitionOnChange
44
+ storageKey="labas-theme"
45
  >
46
+ <div className="min-h-screen bg-background">
47
+ <Sidebar />
48
+ <main className={`min-h-screen transition-all duration-300 ${collapsed ? "md:ml-16" : "md:ml-64"}`}>
49
+ <Outlet />
50
+ </main>
51
  </div>
52
  <Toaster richColors />
53
  </ThemeProvider>
apps/web/src/routes/dashboard.tsx DELETED
@@ -1,33 +0,0 @@
1
- import { useQuery } from "@tanstack/react-query";
2
- import { createFileRoute, redirect } from "@tanstack/react-router";
3
-
4
- import { authClient } from "@/lib/auth-client";
5
- import { trpc } from "@/utils/trpc";
6
-
7
- export const Route = createFileRoute("/dashboard")({
8
- component: RouteComponent,
9
- beforeLoad: async () => {
10
- const session = await authClient.getSession();
11
- if (!session.data) {
12
- redirect({
13
- to: "/login",
14
- throw: true,
15
- });
16
- }
17
- return { session };
18
- },
19
- });
20
-
21
- function RouteComponent() {
22
- const { session } = Route.useRouteContext();
23
-
24
- const privateData = useQuery(trpc.privateData.queryOptions());
25
-
26
- return (
27
- <div>
28
- <h1>Dashboard</h1>
29
- <p>Welcome {session.data?.user.name}</p>
30
- <p>API: {privateData.data?.message}</p>
31
- </div>
32
- );
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/generate.tsx ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { trpc } from "@/utils/trpc";
6
+ import { useApiKey } from "@/hooks/use-api-key";
7
+ import { Button } from "@labas/ui/components/button";
8
+ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
9
+ import "flag-icons/css/flag-icons.min.css";
10
+ import type { GenerationResult } from "@labas/ai";
11
+
12
+ const EXAM_TYPES = [
13
+ { id: "IELTS", name: "IELTS Academic", code: "gb" },
14
+ { id: "TOEFL", name: "TOEFL iBT", code: "us" },
15
+ { id: "JLPT", name: "JLPT", code: "jp" },
16
+ { id: "HSK", name: "HSK", code: "cn" },
17
+ { id: "GOETHE", name: "Goethe-Zertifikat", code: "de" },
18
+ ];
19
+
20
+ const SECTIONS = [
21
+ { id: "READING", name: "Reading", icon: "menu_book" },
22
+ { id: "WRITING", name: "Writing", icon: "edit_note" },
23
+ ];
24
+
25
+ const FORMATS = [
26
+ { id: "multiple_choice", name: "Multiple Choice" },
27
+ { id: "true_false_not_given", name: "True / False / Not Given" },
28
+ { id: "fill_blank", name: "Fill in Blank" },
29
+ { id: "synonym", name: "Synonym / Vocabulary" },
30
+ { id: "grammar_in_context", name: "Grammar in Context" },
31
+ { id: "sentence_completion", name: "Sentence Completion" },
32
+ { id: "cloze", name: "Cloze Test" },
33
+ { id: "reference", name: "Reference (Pronoun)" },
34
+ { id: "author_view", name: "Author's View" },
35
+ { id: "matching_headings", name: "Matching Headings" },
36
+ { id: "kanji_reading", name: "Kanji Reading (JLPT)" },
37
+ { id: "particle_choice", name: "Particle Choice (JLPT)" },
38
+ { id: "article_case", name: "Article / Case (German)" },
39
+ ];
40
+
41
+ const TOPICS = ["Science & Tech", "Business", "Sociology", "Arts", "History", "Environment", "Health", "Education"];
42
+ const DIFFICULTIES = ["Beginner", "Intermediate", "Academic", "Expert"];
43
+
44
+ export const Route = createFileRoute("/generate")({
45
+ component: RouteComponent,
46
+ beforeLoad: async () => {
47
+ const session = await authClient.getSession();
48
+ if (!session.data) {
49
+ redirect({ to: "/login", throw: true });
50
+ }
51
+ return { session };
52
+ },
53
+ });
54
+
55
+ function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
56
+ return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
57
+ }
58
+
59
+ function RouteComponent() {
60
+ const { storedKey, hasKey } = useApiKey();
61
+
62
+ const [examType, setExamType] = useState("IELTS");
63
+ const [section, setSection] = useState("READING");
64
+ const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
65
+ const [difficulty, setDifficulty] = useState(2);
66
+ const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
67
+ const [questionCount, setQuestionCount] = useState(5);
68
+ const [weaknessAlign, setWeaknessAlign] = useState(75);
69
+ const [result, setResult] = useState<GenerationResult | null>(null);
70
+ const [error, setError] = useState<string | null>(null);
71
+
72
+ const generate = useMutation({
73
+ ...trpc.ai.generate.mutationOptions(),
74
+ onSuccess: (data) => {
75
+ setResult(data);
76
+ setError(null);
77
+ },
78
+ onError: (err) => {
79
+ setError(err.message);
80
+ setResult(null);
81
+ },
82
+ });
83
+
84
+ const saveQuestions = useMutation({
85
+ ...trpc.ai.saveQuestions.mutationOptions(),
86
+ onSuccess: () => {
87
+ alert("Soal berhasil disimpan!");
88
+ },
89
+ });
90
+
91
+ const toggleFormat = (id: string) => {
92
+ setSelectedFormats((prev) =>
93
+ prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
94
+ );
95
+ };
96
+
97
+ const toggleTopic = (topic: string) => {
98
+ setSelectedTopics((prev) =>
99
+ prev.includes(topic) ? prev.filter((t) => t !== topic) : [...prev, topic],
100
+ );
101
+ };
102
+
103
+ const handleGenerate = () => {
104
+ if (!hasKey || !storedKey) {
105
+ setError("API key belum dikonfigurasi. Tambahkan di Settings.");
106
+ return;
107
+ }
108
+
109
+ generate.mutate({
110
+ examType: examType as any,
111
+ section: section as any,
112
+ formats: selectedFormats as any,
113
+ difficulty: difficulty + 1,
114
+ topics: selectedTopics,
115
+ questionCount,
116
+ mode: "quick",
117
+ apiKeyConfig: {
118
+ baseUrl: storedKey.baseUrl,
119
+ apiKey: storedKey.apiKey,
120
+ model: storedKey.modelName,
121
+ },
122
+ });
123
+ };
124
+
125
+ return (
126
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
127
+ {/* Header Section */}
128
+ <section className="flex flex-col gap-2 relative mb-10">
129
+ <div className="absolute -left-8 -top-8 w-64 h-64 ai-glow pointer-events-none opacity-50" />
130
+ <h1 className="text-4xl md:text-5xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
131
+ AI Exam Generator
132
+ </h1>
133
+ <p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
134
+ Generate soal latihan reading comprehension dengan AI. Gunakan API key sendiri untuk latihan tanpa batas.
135
+ </p>
136
+ </section>
137
+
138
+ {!hasKey && (
139
+ <div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
140
+ <MaterialIcon name="warning" />
141
+ <span>API key belum dikonfigurasi.</span>
142
+ <Link to="/settings" className="font-semibold underline">
143
+ Tambahkan di Settings β†’
144
+ </Link>
145
+ </div>
146
+ )}
147
+
148
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
149
+ {/* Configuration Panel (Left) */}
150
+ <div className="lg:col-span-8 flex flex-col gap-8">
151
+ {/* Exam Type */}
152
+ <div className="flex flex-col gap-4">
153
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
154
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
155
+ {EXAM_TYPES.map((t) => (
156
+ <button
157
+ key={t.id}
158
+ onClick={() => setExamType(t.id)}
159
+ className={`flex items-center gap-3 py-4 px-4 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover ${
160
+ examType === t.id
161
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
162
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
163
+ }`}
164
+ >
165
+ <span className={`fi fi-${t.code} w-6 h-4 rounded-sm shadow-sm`} />
166
+ {t.name}
167
+ </button>
168
+ ))}
169
+ </div>
170
+ </div>
171
+
172
+ {/* Weakness Alignment */}
173
+ <div className="flex flex-col gap-4">
174
+ <div className="flex justify-between items-end">
175
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
176
+ <span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
177
+ Intelligent Focus
178
+ </span>
179
+ </div>
180
+ <div className="relative py-4">
181
+ <input
182
+ type="range"
183
+ min="0"
184
+ max="100"
185
+ value={weaknessAlign}
186
+ onChange={(e) => setWeaknessAlign(Number(e.target.value))}
187
+ className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
188
+ />
189
+ <div className="flex justify-between mt-4 text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)]">
190
+ <span>Soal Seimbang</span>
191
+ <span>Fokus Kelemahan</span>
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ {/* Difficulty Tuning */}
197
+ <div className="flex flex-col gap-4">
198
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
199
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
200
+ {DIFFICULTIES.map((d, i) => (
201
+ <button
202
+ key={d}
203
+ onClick={() => setDifficulty(i)}
204
+ className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover ${
205
+ difficulty === i
206
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
207
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
208
+ }`}
209
+ >
210
+ {d}
211
+ </button>
212
+ ))}
213
+ </div>
214
+ </div>
215
+
216
+ {/* Module Selection */}
217
+ <div className="flex flex-col gap-4">
218
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
219
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
220
+ {SECTIONS.map((s) => (
221
+ <div
222
+ key={s.id}
223
+ onClick={() => setSection(s.id)}
224
+ className={`flex items-center justify-between p-5 rounded-[var(--radius-lg)] border-2 group cursor-pointer transition-all clay-hover ${
225
+ section === s.id
226
+ ? "bg-[var(--pure-white)] border-[var(--clay-black)] clay-shadow"
227
+ : "bg-[var(--pure-white)] border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
228
+ }`}
229
+ >
230
+ <div className="flex items-center gap-4">
231
+ <MaterialIcon
232
+ name={s.icon}
233
+ className={`text-[var(--clay-black)] group-hover:scale-110 transition-transform ${section === s.id ? "text-[var(--clay-black)]" : ""}`}
234
+ />
235
+ <span className="font-semibold text-[var(--clay-black)]">{s.name}</span>
236
+ </div>
237
+ <input
238
+ type="checkbox"
239
+ checked={section === s.id}
240
+ readOnly
241
+ className="w-5 h-5 rounded border-[var(--oat-border)] text-[var(--clay-black)] focus:ring-[var(--clay-black)]"
242
+ />
243
+ </div>
244
+ ))}
245
+ </div>
246
+ </div>
247
+
248
+ {/* Format Selection */}
249
+ <div className="flex flex-col gap-4">
250
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
251
+ <div className="flex flex-wrap gap-2">
252
+ {FORMATS.map((f) => (
253
+ <button
254
+ key={f.id}
255
+ onClick={() => toggleFormat(f.id)}
256
+ className={`px-4 py-2.5 rounded-full text-sm font-medium flex items-center gap-2 cursor-pointer transition-all clay-hover ${
257
+ selectedFormats.includes(f.id)
258
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
259
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
260
+ }`}
261
+ >
262
+ {f.name}
263
+ {selectedFormats.includes(f.id) && (
264
+ <MaterialIcon name="close" className="text-sm" />
265
+ )}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ </div>
270
+
271
+ {/* Topic Focus */}
272
+ <div className="flex flex-col gap-4">
273
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
274
+ <div className="flex flex-wrap gap-2">
275
+ {selectedTopics.map((topic) => (
276
+ <span
277
+ key={topic}
278
+ onClick={() => toggleTopic(topic)}
279
+ className="px-4 py-2 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] font-medium flex items-center gap-2 cursor-pointer transition-all hover:brightness-95 clay-hover"
280
+ >
281
+ {topic} <MaterialIcon name="close" className="text-sm" />
282
+ </span>
283
+ ))}
284
+ {TOPICS.filter((t) => !selectedTopics.includes(t)).map((topic) => (
285
+ <button
286
+ key={topic}
287
+ onClick={() => toggleTopic(topic)}
288
+ className="px-4 py-2 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] font-medium hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)] transition-all clay-hover"
289
+ >
290
+ {topic}
291
+ </button>
292
+ ))}
293
+ </div>
294
+ </div>
295
+
296
+ {/* Question Count */}
297
+ <div className="flex flex-col gap-4">
298
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jumlah Soal: {questionCount}</label>
299
+ <input
300
+ type="range"
301
+ min={1}
302
+ max={20}
303
+ value={questionCount}
304
+ onChange={(e) => setQuestionCount(Number(e.target.value))}
305
+ className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
306
+ />
307
+ </div>
308
+ </div>
309
+
310
+ {/* Live Preview Card (Right) */}
311
+ <div className="lg:col-span-4 sticky top-8">
312
+ <Card className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] clay-shadow rounded-[var(--radius-xl)]">
313
+ <CardHeader>
314
+ <div className="flex items-center gap-3">
315
+ <MaterialIcon name="analytics" className="text-[var(--matcha-600)]" />
316
+ <CardTitle className="font-headline text-xl text-[var(--clay-black)]">Test Blueprint</CardTitle>
317
+ </div>
318
+ </CardHeader>
319
+ <CardContent className="space-y-6">
320
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
321
+ <span className="text-[var(--warm-charcoal)]">Estimasi Durasi</span>
322
+ <span className="font-bold text-[var(--clay-black)]">{questionCount * 2} Menit</span>
323
+ </div>
324
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
325
+ <span className="text-[var(--warm-charcoal)]">Jumlah Soal</span>
326
+ <span className="font-bold text-[var(--clay-black)]">{questionCount} Soal</span>
327
+ </div>
328
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
329
+ <span className="text-[var(--warm-charcoal)]">Format</span>
330
+ <span className="font-bold text-[var(--clay-black)]">{selectedFormats.length} Jenis</span>
331
+ </div>
332
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
333
+ <span className="text-[var(--warm-charcoal)]">Ujian</span>
334
+ <span className="font-bold text-[var(--clay-black)]">
335
+ {EXAM_TYPES.find((t) => t.id === examType)?.name}
336
+ </span>
337
+ </div>
338
+
339
+ {/* AI Confidence Score Orbit */}
340
+ <div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-6 flex flex-col items-center gap-4 relative overflow-hidden">
341
+ <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--matcha-600)] to-transparent" />
342
+ <span className="text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)] font-bold">
343
+ AI Confidence Score
344
+ </span>
345
+ <div className="relative flex items-center justify-center">
346
+ <svg className="w-32 h-32 transform -rotate-90">
347
+ <circle className="text-[var(--warm-silver)]" cx="64" cy="64" fill="transparent" r="56" stroke="currentColor" strokeWidth="8" />
348
+ <circle
349
+ className="text-[var(--matcha-600)]"
350
+ cx="64"
351
+ cy="64"
352
+ fill="transparent"
353
+ r="56"
354
+ stroke="currentColor"
355
+ strokeDasharray="351.85"
356
+ strokeDashoffset={351.85 - (351.85 * (weaknessAlign / 100))}
357
+ strokeWidth="8"
358
+ />
359
+ </svg>
360
+ <span className="absolute text-3xl font-headline font-extrabold text-[var(--clay-black)]">{Math.round(weaknessAlign)}%</span>
361
+ </div>
362
+ <p className="text-[11px] text-center text-[var(--warm-charcoal)] px-2 leading-tight">
363
+ Probabilitas tinggi untuk mengatasi blindspot linguistik utama.
364
+ </p>
365
+ </div>
366
+
367
+ {/* Primary CTA */}
368
+ <Button
369
+ className="w-full py-5 rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] font-bold text-lg flex items-center justify-center gap-3 clay-shadow clay-hover hover:bg-[var(--warm-charcoal)] transition-all active:scale-95 h-auto"
370
+ onClick={handleGenerate}
371
+ disabled={generate.isPending || selectedFormats.length === 0 || !hasKey}
372
+ >
373
+ <MaterialIcon name="auto_awesome" className="group-hover:rotate-12 transition-transform" />
374
+ {generate.isPending ? "Generating..." : "Generate & Launch"}
375
+ </Button>
376
+
377
+ {error && (
378
+ <div className="p-4 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm border-2 border-[var(--badge-blue-bg)]">
379
+ {error}
380
+ </div>
381
+ )}
382
+ </CardContent>
383
+ </Card>
384
+ </div>
385
+ </div>
386
+
387
+ {/* Results Section */}
388
+ {result && (
389
+ <div className="mt-16 space-y-6">
390
+ <div className="flex items-center justify-between">
391
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h2>
392
+ <Button
393
+ onClick={() =>
394
+ saveQuestions.mutate({
395
+ examTypeId: examType,
396
+ sectionTypeId: section,
397
+ questions: result.questions,
398
+ isPublic: false,
399
+ })
400
+ }
401
+ disabled={saveQuestions.isPending}
402
+ className="bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover"
403
+ >
404
+ {saveQuestions.isPending ? "Menyimpan..." : "Simpan ke Bank Soal"}
405
+ </Button>
406
+ </div>
407
+
408
+ <div className="text-sm text-[var(--warm-charcoal)] mb-4 font-mono">
409
+ Model: {result.meta.model} Β· Tokens: {result.meta.tokensUsed ?? "?"} Β· Waktu: {result.meta.durationMs}ms
410
+ </div>
411
+
412
+ {result.questions.map((q, idx) => (
413
+ <Card key={idx} className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
414
+ <CardHeader>
415
+ <div className="flex items-center gap-3">
416
+ <span className="bg-[var(--matcha-600)] text-[var(--pure-white)] w-8 h-8 flex items-center justify-center rounded-[var(--radius-md)] font-bold text-sm">
417
+ {idx + 1}
418
+ </span>
419
+ <span className="text-sm font-bold text-[var(--matcha-600)] uppercase tracking-tighter">
420
+ {q.format.replace(/_/g, " ")}
421
+ </span>
422
+ </div>
423
+ </CardHeader>
424
+ <CardContent className="space-y-4">
425
+ <div className="p-5 rounded-[var(--radius-lg)] bg-[var(--oat-light)] text-[var(--clay-black)] leading-relaxed whitespace-pre-wrap text-base">
426
+ {q.passageText}
427
+ </div>
428
+ <p className="font-medium text-[var(--clay-black)] text-lg">{q.questionText}</p>
429
+ {"options" in q && q.options && (
430
+ <div className="space-y-2">
431
+ {q.options.map((opt) => (
432
+ <label
433
+ key={opt.key}
434
+ className={`flex items-center p-4 rounded-[var(--radius-lg)] border-2 cursor-pointer transition-all clay-hover ${
435
+ opt.key === q.correctAnswer
436
+ ? "border-[var(--clay-black)] bg-[var(--oat-light)]"
437
+ : "border-[var(--oat-border)] bg-[var(--oat-light)] hover:bg-[var(--matcha-300)]/30"
438
+ }`}
439
+ >
440
+ <span className="w-5 h-5 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-4">
441
+ {opt.key === q.correctAnswer && (
442
+ <span className="w-2.5 h-2.5 rounded-full bg-[var(--clay-black)]" />
443
+ )}
444
+ </span>
445
+ <span className={`${opt.key === q.correctAnswer ? "font-semibold text-[var(--clay-black)]" : "text-[var(--clay-black)]"}`}>
446
+ {opt.text}
447
+ </span>
448
+ {opt.key === q.correctAnswer && (
449
+ <span className="ml-auto text-xs font-bold text-[var(--matcha-800)] bg-[var(--matcha-300)] px-2 py-1 rounded-full">
450
+ Benar
451
+ </span>
452
+ )}
453
+ </label>
454
+ ))}
455
+ </div>
456
+ )}
457
+ {!("options" in q) && (
458
+ <div className="p-4 rounded-[var(--radius-md)] border-2 border-[var(--matcha-300)] bg-[var(--matcha-300)]/30 text-sm">
459
+ <span className="font-semibold text-[var(--matcha-800)]">Jawaban: </span>
460
+ <span className="text-[var(--matcha-800)]">{q.correctAnswer}</span>
461
+ </div>
462
+ )}
463
+ <div className="text-sm text-[var(--warm-charcoal)]">
464
+ <span className="font-medium text-[var(--clay-black)]">Penjelasan: </span>
465
+ {q.explanation}
466
+ </div>
467
+ <div className="flex gap-2 flex-wrap">
468
+ {q.skillTags.map((tag) => (
469
+ <span
470
+ key={tag}
471
+ className="px-3 py-1 rounded-full bg-[var(--oat-light)] text-xs font-medium text-[var(--warm-charcoal)]"
472
+ >
473
+ {tag}
474
+ </span>
475
+ ))}
476
+ </div>
477
+ </CardContent>
478
+ </Card>
479
+ ))}
480
+ </div>
481
+ )}
482
+ </div>
483
+ );
484
+ }
apps/web/src/routes/index.tsx CHANGED
@@ -1,50 +1,156 @@
1
  import { useQuery } from "@tanstack/react-query";
2
- import { createFileRoute } from "@tanstack/react-router";
3
-
4
  import { trpc } from "@/utils/trpc";
 
 
5
 
6
  export const Route = createFileRoute("/")({
7
  component: HomeComponent,
 
 
 
 
 
 
 
8
  });
9
 
10
- const TITLE_TEXT = `
11
- β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
12
- β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—
13
- β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•
14
- β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—
15
- β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘
16
- β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β•
17
-
18
- β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—
19
- β•šβ•β•β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•
20
- β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•
21
- β–ˆβ–ˆβ•‘ β•šβ•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•—
22
- β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•—
23
- β•šβ•β• β•šβ•β•β•β•β•β•β• β•šβ•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β•β•šβ•β• β•šβ•β•
24
- `;
25
 
26
  function HomeComponent() {
27
- const healthCheck = useQuery(trpc.healthCheck.queryOptions());
 
28
 
29
  return (
30
- <div className="container mx-auto max-w-3xl px-4 py-2">
31
- <pre className="overflow-x-auto font-mono text-sm">{TITLE_TEXT}</pre>
32
- <div className="grid gap-6">
33
- <section className="rounded-lg border p-4">
34
- <h2 className="mb-2 font-medium">API Status</h2>
35
- <div className="flex items-center gap-2">
36
- <div
37
- className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
38
- />
39
- <span className="text-sm text-muted-foreground">
40
- {healthCheck.isLoading
41
- ? "Checking..."
42
- : healthCheck.data
43
- ? "Connected"
44
- : "Disconnected"}
45
- </span>
46
- </div>
47
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  </div>
49
  </div>
50
  );
 
1
  import { useQuery } from "@tanstack/react-query";
2
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
+ import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
+ import { Button } from "@labas/ui/components/button";
6
+ import { Card, CardContent } from "@labas/ui/components/card";
7
 
8
  export const Route = createFileRoute("/")({
9
  component: HomeComponent,
10
+ beforeLoad: async () => {
11
+ const session = await authClient.getSession();
12
+ if (!session.data) {
13
+ redirect({ to: "/login", throw: true });
14
+ }
15
+ return { session };
16
+ },
17
  });
18
 
19
+ function MaterialIcon({ name, className = "text-xl" }: { name: string; className?: string }) {
20
+ return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
21
+ }
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  function HomeComponent() {
24
+ const { session } = Route.useRouteContext();
25
+ const privateData = useQuery(trpc.privateData.queryOptions());
26
 
27
  return (
28
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-6xl mx-auto bg-[var(--warm-cream)]">
29
+ <section className="mb-10">
30
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
31
+ Labas
32
+ </h1>
33
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">
34
+ Halo, {session.data?.user.name ?? "Pengguna"}! Platform latihan ujian bahasa dengan AI.
35
+ </p>
36
+ </section>
37
+
38
+ {/* Quick Stats */}
39
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
40
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
41
+ <CardContent className="p-6">
42
+ <div className="flex items-center gap-4">
43
+ <div className="h-12 w-12 bg-[var(--matcha-300)] rounded-[var(--radius-lg)] flex items-center justify-center">
44
+ <MaterialIcon name="assignment" className="text-xl text-[var(--matcha-800)]" />
45
+ </div>
46
+ <div>
47
+ <p className="text-sm text-[var(--warm-charcoal)]">Soal Dikerjakan</p>
48
+ <p className="text-2xl font-bold text-[var(--clay-black)] font-headline">0</p>
49
+ </div>
50
+ </div>
51
+ </CardContent>
52
+ </Card>
53
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
54
+ <CardContent className="p-6">
55
+ <div className="flex items-center gap-4">
56
+ <div className="h-12 w-12 bg-[var(--slushie-500)] rounded-[var(--radius-lg)] flex items-center justify-center">
57
+ <MaterialIcon name="timer" className="text-xl text-[var(--clay-black)]" />
58
+ </div>
59
+ <div>
60
+ <p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
61
+ <p className="text-2xl font-bold text-[var(--clay-black)] font-headline">0 jam</p>
62
+ </div>
63
+ </div>
64
+ </CardContent>
65
+ </Card>
66
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
67
+ <CardContent className="p-6">
68
+ <div className="flex items-center gap-4">
69
+ <div className="h-12 w-12 bg-[var(--lemon-400)] rounded-[var(--radius-lg)] flex items-center justify-center">
70
+ <MaterialIcon name="percent" className="text-xl text-[var(--lemon-800)]" />
71
+ </div>
72
+ <div>
73
+ <p className="text-sm text-[var(--warm-charcoal)]">Akurasi Rata-rata</p>
74
+ <p className="text-2xl font-bold text-[var(--clay-black)] font-headline">-</p>
75
+ </div>
76
+ </div>
77
+ </CardContent>
78
+ </Card>
79
+ </div>
80
+
81
+ {/* Quick Actions */}
82
+ <div className="flex flex-col md:flex-row gap-4 mb-10">
83
+ <Link to="/generate">
84
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 px-6 clay-shadow clay-hover">
85
+ <MaterialIcon name="auto_awesome" />
86
+ <span className="ml-2">Generate Soal Baru</span>
87
+ </Button>
88
+ </Link>
89
+ <Link to="/settings">
90
+ <Button variant="outline" className="rounded-[var(--radius-lg)] h-11 px-6 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover">
91
+ <MaterialIcon name="key" />
92
+ <span className="ml-2">Atur API Key</span>
93
+ </Button>
94
+ </Link>
95
+ </div>
96
+
97
+ {/* Feature Cards */}
98
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
99
+ <Link to="/generate" className="block">
100
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
101
+ <CardContent className="p-6">
102
+ <div className="flex items-center gap-3 mb-4">
103
+ <div className="h-10 w-10 bg-[var(--matcha-300)] rounded-[var(--radius-md)] flex items-center justify-center">
104
+ <MaterialIcon name="auto_awesome" className="text-xl text-[var(--matcha-800)]" />
105
+ </div>
106
+ <h3 className="font-headline text-lg font-bold text-[var(--clay-black)]">AI Generator</h3>
107
+ </div>
108
+ <p className="text-sm text-[var(--warm-charcoal)]">
109
+ Generate soal dengan AI menggunakan API key sendiri. Dua mode: Quick dan Agentic.
110
+ </p>
111
+ </CardContent>
112
+ </Card>
113
+ </Link>
114
+
115
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full opacity-70">
116
+ <CardContent className="p-6">
117
+ <div className="flex items-center gap-3 mb-4">
118
+ <div className="h-10 w-10 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
119
+ <MaterialIcon name="database" className="text-xl text-[var(--warm-silver)]" />
120
+ </div>
121
+ <h3 className="font-headline text-lg font-bold text-[var(--warm-silver)]">Bank Soal</h3>
122
+ </div>
123
+ <p className="text-sm text-[var(--warm-silver)]">
124
+ Simpan, publikasikan, dan gunakan ulang soal. Segera hadir.
125
+ </p>
126
+ </CardContent>
127
+ </Card>
128
+
129
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full opacity-70">
130
+ <CardContent className="p-6">
131
+ <div className="flex items-center gap-3 mb-4">
132
+ <div className="h-10 w-10 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
133
+ <MaterialIcon name="analytics" className="text-xl text-[var(--warm-silver)]" />
134
+ </div>
135
+ <h3 className="font-headline text-lg font-bold text-[var(--warm-silver)]">Analytics</h3>
136
+ </div>
137
+ <p className="text-sm text-[var(--warm-silver)]">
138
+ Evaluasi per-section, identifikasi kelemahan. Segera hadir.
139
+ </p>
140
+ </CardContent>
141
+ </Card>
142
+ </div>
143
+
144
+ {/* API Status */}
145
+ <div className="mt-10 flex items-center gap-2 text-sm text-[var(--warm-charcoal)]">
146
+ <div className={`h-2 w-2 rounded-full ${privateData.data ? "bg-[var(--matcha-600)]" : "bg-[var(--pomegranate-400)]"}`} />
147
+ <span>
148
+ {privateData.isLoading
149
+ ? "Checking..."
150
+ : privateData.data
151
+ ? "Server Connected"
152
+ : "Server Disconnected"}
153
+ </span>
154
  </div>
155
  </div>
156
  );
apps/web/src/routes/settings.tsx ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { createFileRoute, redirect } from "@tanstack/react-router";
3
+ import { authClient } from "@/lib/auth-client";
4
+ import { useApiKey } from "@/hooks/use-api-key";
5
+ import { Button } from "@labas/ui/components/button";
6
+ import { Input } from "@labas/ui/components/input";
7
+ import { Label } from "@labas/ui/components/label";
8
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
9
+
10
+ export const Route = createFileRoute("/settings")({
11
+ component: RouteComponent,
12
+ beforeLoad: async () => {
13
+ const session = await authClient.getSession();
14
+ if (!session.data) {
15
+ redirect({ to: "/login", throw: true });
16
+ }
17
+ return { session };
18
+ },
19
+ });
20
+
21
+ function MaterialIcon({ name }: { name: string }) {
22
+ return <span className="material-symbols-outlined text-xl">{name}</span>;
23
+ }
24
+
25
+ function RouteComponent() {
26
+ const { storedKey, isLoading, saveKey, removeKey, hasKey } = useApiKey();
27
+
28
+ const [provider, setProvider] = useState(storedKey?.provider ?? "openai");
29
+ const [baseUrl, setBaseUrl] = useState(storedKey?.baseUrl ?? "https://api.openai.com/v1");
30
+ const [apiKey, setApiKey] = useState("");
31
+ const [modelName, setModelName] = useState(storedKey?.modelName ?? "gpt-4o-mini");
32
+ const [isSaving, setIsSaving] = useState(false);
33
+
34
+ const handleSave = async () => {
35
+ if (!apiKey) return;
36
+ setIsSaving(true);
37
+ try {
38
+ await saveKey({ provider, baseUrl, apiKey, modelName });
39
+ setApiKey("");
40
+ } finally {
41
+ setIsSaving(false);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
47
+ <section className="mb-10">
48
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">Pengaturan</h1>
49
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">Kelola API key dan preferensi akun.</p>
50
+ </section>
51
+
52
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
53
+ <div className="lg:col-span-7 space-y-8">
54
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
55
+ <CardHeader>
56
+ <div className="flex items-center gap-3">
57
+ <MaterialIcon name="key" />
58
+ <CardTitle className="font-headline text-[var(--clay-black)]">
59
+ {hasKey ? "Update API Key" : "Tambah API Key"}
60
+ </CardTitle>
61
+ </div>
62
+ <CardDescription className="text-[var(--warm-charcoal)]">
63
+ API key disimpan terenkripsi di browser (localStorage + IndexedDB). Server tidak pernah menyimpan key.
64
+ </CardDescription>
65
+ </CardHeader>
66
+ <CardContent className="space-y-5">
67
+ <div className="grid grid-cols-2 gap-4">
68
+ <div className="space-y-2">
69
+ <Label htmlFor="provider" className="text-[var(--clay-black)]">Provider</Label>
70
+ <select
71
+ id="provider"
72
+ className="flex h-10 w-full rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--clay-black)] focus-visible:ring-offset-2 text-[var(--clay-black)]"
73
+ value={provider}
74
+ onChange={(e) => setProvider(e.target.value)}
75
+ >
76
+ <option value="openai">OpenAI</option>
77
+ <option value="anthropic">Anthropic</option>
78
+ <option value="google">Google</option>
79
+ <option value="openrouter">OpenRouter</option>
80
+ <option value="groq">Groq</option>
81
+ <option value="custom">Custom</option>
82
+ </select>
83
+ </div>
84
+ <div className="space-y-2">
85
+ <Label htmlFor="model" className="text-[var(--clay-black)]">Model</Label>
86
+ <Input
87
+ id="model"
88
+ value={modelName}
89
+ onChange={(e) => setModelName(e.target.value)}
90
+ placeholder="gpt-4o-mini"
91
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
92
+ />
93
+ </div>
94
+ </div>
95
+
96
+ <div className="space-y-2">
97
+ <Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
98
+ <Input
99
+ id="baseUrl"
100
+ value={baseUrl}
101
+ onChange={(e) => setBaseUrl(e.target.value)}
102
+ placeholder="https://api.openai.com/v1"
103
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
104
+ />
105
+ </div>
106
+
107
+ <div className="space-y-2">
108
+ <Label htmlFor="apiKey" className="text-[var(--clay-black)]">
109
+ {hasKey ? "API Key Baru (kosongkan jika tidak diubah)" : "API Key"}
110
+ </Label>
111
+ <Input
112
+ id="apiKey"
113
+ type="password"
114
+ value={apiKey}
115
+ onChange={(e) => setApiKey(e.target.value)}
116
+ placeholder={hasKey ? "β€’β€’β€’β€’β€’β€’β€’β€’" : "sk-..."}
117
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
118
+ />
119
+ </div>
120
+
121
+ <div className="flex gap-3">
122
+ <Button
123
+ onClick={handleSave}
124
+ disabled={(!apiKey && !hasKey) || isSaving}
125
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 clay-shadow clay-hover"
126
+ >
127
+ {isSaving ? "Menyimpan..." : hasKey ? "Update Key" : "Simpan API Key"}
128
+ </Button>
129
+ {hasKey && (
130
+ <Button
131
+ variant="outline"
132
+ onClick={removeKey}
133
+ className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)] text-[var(--pomegranate-400)] hover:bg-[var(--pomegranate-400)]/10 clay-hover"
134
+ >
135
+ Hapus Key
136
+ </Button>
137
+ )}
138
+ </div>
139
+ </CardContent>
140
+ </Card>
141
+ </div>
142
+
143
+ <div className="lg:col-span-5">
144
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-8">
145
+ <CardHeader>
146
+ <CardTitle className="font-headline text-[var(--clay-black)]">Status API Key</CardTitle>
147
+ <CardDescription className="text-[var(--warm-charcoal)]">Informasi penyimpanan key.</CardDescription>
148
+ </CardHeader>
149
+ <CardContent>
150
+ {isLoading ? (
151
+ <p className="text-[var(--warm-charcoal)]">Memuat...</p>
152
+ ) : hasKey ? (
153
+ <div className="space-y-4">
154
+ <div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
155
+ <MaterialIcon name="check_circle" />
156
+ <div>
157
+ <p className="font-medium text-[var(--matcha-800)]">Key Tersimpan</p>
158
+ <p className="text-sm text-[var(--matcha-800)]/70">
159
+ {storedKey?.provider} Β· {storedKey?.modelName}
160
+ </p>
161
+ </div>
162
+ </div>
163
+ <div className="text-xs text-[var(--warm-charcoal)] space-y-1">
164
+ <p>β€’ Disimpan terenkripsi di localStorage</p>
165
+ <p>β€’ Key enkripsi disimpan di IndexedDB</p>
166
+ <p>β€’ Server tidak pernah menyimpan key</p>
167
+ </div>
168
+ </div>
169
+ ) : (
170
+ <div className="text-center py-8">
171
+ <MaterialIcon name="key_off" />
172
+ <p className="text-[var(--warm-charcoal)] mt-2">Belum ada API key.</p>
173
+ <p className="text-xs text-[var(--warm-charcoal)] mt-1">
174
+ Tambahkan key untuk mulai generate soal.
175
+ </p>
176
+ </div>
177
+ )}
178
+ </CardContent>
179
+ </Card>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ );
184
+ }
apps/web/tsconfig.json CHANGED
@@ -11,7 +11,9 @@
11
  "rootDirs": ["."],
12
  "paths": {
13
  "@/*": ["./src/*"],
14
- "@labas/ui/*": ["../../packages/ui/src/*"]
 
 
15
  }
16
  }
17
  }
 
11
  "rootDirs": ["."],
12
  "paths": {
13
  "@/*": ["./src/*"],
14
+ "@labas/ui/*": ["../../packages/ui/src/*"],
15
+ "@labas/ai": ["../../packages/ai/src/index.ts"],
16
+ "@labas/ai/*": ["../../packages/ai/src/*.ts"]
17
  }
18
  }
19
  }
bun.lock CHANGED
@@ -42,6 +42,7 @@
42
  "version": "0.0.0",
43
  "dependencies": {
44
  "@hookform/resolvers": "^5.2.2",
 
45
  "@labas/api": "workspace:*",
46
  "@labas/auth": "workspace:*",
47
  "@labas/env": "workspace:*",
@@ -55,6 +56,7 @@
55
  "@trpc/tanstack-react-query": "^11.16.0",
56
  "better-auth": "catalog:",
57
  "dotenv": "catalog:",
 
58
  "lucide-react": "^1.8.0",
59
  "next-themes": "catalog:",
60
  "react": "^19.2.5",
@@ -79,15 +81,27 @@
79
  "vite": "^8.0.8",
80
  },
81
  },
 
 
 
 
 
 
 
 
 
 
82
  "packages/api": {
83
  "name": "@labas/api",
84
  "dependencies": {
 
85
  "@labas/auth": "workspace:*",
86
  "@labas/db": "workspace:*",
87
  "@labas/env": "workspace:*",
88
  "@trpc/client": "catalog:",
89
  "@trpc/server": "catalog:",
90
  "dotenv": "catalog:",
 
91
  "zod": "catalog:",
92
  },
93
  "devDependencies": {
@@ -542,6 +556,8 @@
542
 
543
  "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
544
 
 
 
545
  "@labas/api": ["@labas/api@workspace:packages/api"],
546
 
547
  "@labas/auth": ["@labas/auth@workspace:packages/auth"],
@@ -1036,6 +1052,8 @@
1036
 
1037
  "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
1038
 
 
 
1039
  "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
1040
 
1041
  "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@@ -1662,7 +1680,7 @@
1662
 
1663
  "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
1664
 
1665
- "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
1666
 
1667
  "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
1668
 
@@ -1744,11 +1762,11 @@
1744
 
1745
  "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
1746
 
1747
- "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
1748
 
1749
  "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
1750
 
1751
- "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
1752
 
1753
  "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
1754
 
@@ -1900,8 +1918,6 @@
1900
 
1901
  "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
1902
 
1903
- "mongodb-connection-string-url/whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
1904
-
1905
  "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
1906
 
1907
  "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
@@ -2018,10 +2034,6 @@
2018
 
2019
  "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
2020
 
2021
- "mongodb-connection-string-url/whatwg-url/tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
2022
-
2023
- "mongodb-connection-string-url/whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
2024
-
2025
  "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="],
2026
 
2027
  "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
@@ -2076,6 +2088,8 @@
2076
 
2077
  "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
2078
 
 
 
2079
  "workbox-build/stringify-object/is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="],
2080
 
2081
  "workbox-build/stringify-object/is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="],
@@ -2092,6 +2106,10 @@
2092
 
2093
  "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
2094
 
 
 
 
 
2095
  "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
2096
  }
2097
  }
 
42
  "version": "0.0.0",
43
  "dependencies": {
44
  "@hookform/resolvers": "^5.2.2",
45
+ "@labas/ai": "workspace:*",
46
  "@labas/api": "workspace:*",
47
  "@labas/auth": "workspace:*",
48
  "@labas/env": "workspace:*",
 
56
  "@trpc/tanstack-react-query": "^11.16.0",
57
  "better-auth": "catalog:",
58
  "dotenv": "catalog:",
59
+ "flag-icons": "^7.5.0",
60
  "lucide-react": "^1.8.0",
61
  "next-themes": "catalog:",
62
  "react": "^19.2.5",
 
81
  "vite": "^8.0.8",
82
  },
83
  },
84
+ "packages/ai": {
85
+ "name": "@labas/ai",
86
+ "dependencies": {
87
+ "zod": "catalog:",
88
+ },
89
+ "devDependencies": {
90
+ "@labas/config": "workspace:*",
91
+ "typescript": "catalog:",
92
+ },
93
+ },
94
  "packages/api": {
95
  "name": "@labas/api",
96
  "dependencies": {
97
+ "@labas/ai": "workspace:*",
98
  "@labas/auth": "workspace:*",
99
  "@labas/db": "workspace:*",
100
  "@labas/env": "workspace:*",
101
  "@trpc/client": "catalog:",
102
  "@trpc/server": "catalog:",
103
  "dotenv": "catalog:",
104
+ "drizzle-orm": "^0.45.1",
105
  "zod": "catalog:",
106
  },
107
  "devDependencies": {
 
556
 
557
  "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
558
 
559
+ "@labas/ai": ["@labas/ai@workspace:packages/ai"],
560
+
561
  "@labas/api": ["@labas/api@workspace:packages/api"],
562
 
563
  "@labas/auth": ["@labas/auth@workspace:packages/auth"],
 
1052
 
1053
  "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
1054
 
1055
+ "flag-icons": ["flag-icons@7.5.0", "", {}, "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg=="],
1056
+
1057
  "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
1058
 
1059
  "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
 
1680
 
1681
  "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
1682
 
1683
+ "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
1684
 
1685
  "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
1686
 
 
1762
 
1763
  "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
1764
 
1765
+ "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
1766
 
1767
  "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
1768
 
1769
+ "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
1770
 
1771
  "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
1772
 
 
1918
 
1919
  "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
1920
 
 
 
1921
  "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
1922
 
1923
  "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
 
2034
 
2035
  "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
2036
 
 
 
 
 
2037
  "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="],
2038
 
2039
  "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
 
2088
 
2089
  "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
2090
 
2091
+ "workbox-build/source-map/whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
2092
+
2093
  "workbox-build/stringify-object/is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="],
2094
 
2095
  "workbox-build/stringify-object/is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="],
 
2106
 
2107
  "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
2108
 
2109
+ "workbox-build/source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
2110
+
2111
+ "workbox-build/source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
2112
+
2113
  "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
2114
  }
2115
  }
packages/ai/package.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@labas/ai",
3
+ "type": "module",
4
+ "exports": {
5
+ ".": {
6
+ "default": "./src/index.ts"
7
+ },
8
+ "./schemas": {
9
+ "default": "./src/schemas.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "zod": "catalog:"
14
+ },
15
+ "devDependencies": {
16
+ "@labas/config": "workspace:*",
17
+ "typescript": "catalog:"
18
+ }
19
+ }
packages/ai/src/client.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface ChatMessage {
2
+ role: "system" | "user" | "assistant";
3
+ content: string;
4
+ }
5
+
6
+ export interface ChatCompletionOptions {
7
+ messages: ChatMessage[];
8
+ model: string;
9
+ temperature?: number;
10
+ max_tokens?: number;
11
+ response_format?: { type: "json_object" };
12
+ }
13
+
14
+ export interface ChatCompletionResponse {
15
+ id: string;
16
+ choices: {
17
+ message: ChatMessage;
18
+ finish_reason: string;
19
+ }[];
20
+ usage?: {
21
+ prompt_tokens: number;
22
+ completion_tokens: number;
23
+ total_tokens: number;
24
+ };
25
+ }
26
+
27
+ export class OpenAICompatibleClient {
28
+ constructor(
29
+ private baseUrl: string,
30
+ private apiKey: string,
31
+ ) {}
32
+
33
+ async chatCompletion(opts: ChatCompletionOptions): Promise<ChatCompletionResponse> {
34
+ const url = `${this.baseUrl.replace(/\/$/, "")}/chat/completions`;
35
+ const body = {
36
+ model: opts.model,
37
+ messages: opts.messages,
38
+ temperature: opts.temperature ?? 0.7,
39
+ max_tokens: opts.max_tokens ?? 4096,
40
+ ...(opts.response_format ? { response_format: opts.response_format } : {}),
41
+ };
42
+
43
+ const res = await fetch(url, {
44
+ method: "POST",
45
+ headers: {
46
+ "Content-Type": "application/json",
47
+ Authorization: `Bearer ${this.apiKey}`,
48
+ },
49
+ body: JSON.stringify(body),
50
+ });
51
+
52
+ if (!res.ok) {
53
+ const text = await res.text();
54
+ throw new Error(`OpenAI-compatible API error ${res.status}: ${text}`);
55
+ }
56
+
57
+ return res.json() as Promise<ChatCompletionResponse>;
58
+ }
59
+ }
packages/ai/src/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export { OpenAICompatibleClient } from "./client";
2
+ export { generateQuestionsQuick } from "./pipeline";
3
+ export { buildQuickModePrompt } from "./prompts";
4
+ export * from "./schemas";
packages/ai/src/pipeline.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { OpenAICompatibleClient } from "./client";
2
+ import { buildQuickModePrompt } from "./prompts";
3
+ import {
4
+ questionSchema,
5
+ type GenerationInput,
6
+ type GenerationResult,
7
+ } from "./schemas";
8
+
9
+ export async function generateQuestionsQuick(
10
+ input: GenerationInput,
11
+ ): Promise<GenerationResult> {
12
+ const start = Date.now();
13
+ const client = new OpenAICompatibleClient(
14
+ input.apiKeyConfig.baseUrl,
15
+ input.apiKeyConfig.apiKey,
16
+ );
17
+
18
+ const prompt = buildQuickModePrompt(input);
19
+
20
+ const response = await client.chatCompletion({
21
+ model: input.apiKeyConfig.model,
22
+ messages: [
23
+ {
24
+ role: "system",
25
+ content:
26
+ "You are a precise exam question generator. You always return valid JSON. You never include markdown formatting around the JSON.",
27
+ },
28
+ { role: "user", content: prompt },
29
+ ],
30
+ temperature: 0.7,
31
+ max_tokens: 4096,
32
+ response_format: { type: "json_object" },
33
+ });
34
+
35
+ const content = response.choices[0]?.message.content;
36
+ if (!content) {
37
+ throw new Error("Empty response from AI");
38
+ }
39
+
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(content);
43
+ } catch {
44
+ // Sometimes LLM wraps in markdown β€” strip it
45
+ const cleaned = content
46
+ .replace(/^```json\s*/, "")
47
+ .replace(/```\s*$/, "")
48
+ .trim();
49
+ parsed = JSON.parse(cleaned);
50
+ }
51
+
52
+ if (!parsed || typeof parsed !== "object") {
53
+ throw new Error("Invalid JSON response from AI");
54
+ }
55
+
56
+ const raw = parsed as Record<string, unknown>;
57
+ if (!Array.isArray(raw.questions)) {
58
+ throw new Error("Missing 'questions' array in AI response");
59
+ }
60
+
61
+ const questions = raw.questions
62
+ .map((q: unknown) => {
63
+ try {
64
+ return questionSchema.parse(q);
65
+ } catch (e) {
66
+ console.warn("Question validation failed:", e);
67
+ return null;
68
+ }
69
+ })
70
+ .filter((q): q is NonNullable<typeof q> => q !== null);
71
+
72
+ if (questions.length === 0) {
73
+ throw new Error("No valid questions generated");
74
+ }
75
+
76
+ return {
77
+ questions,
78
+ meta: {
79
+ model: input.apiKeyConfig.model,
80
+ tokensUsed: response.usage?.total_tokens,
81
+ durationMs: Date.now() - start,
82
+ mode: "quick",
83
+ },
84
+ };
85
+ }
packages/ai/src/prompts.ts ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { GenerationInput } from "./schemas";
2
+
3
+ export function buildQuickModePrompt(input: GenerationInput): string {
4
+ const { examType, section, formats, difficulty, topics, questionCount } = input;
5
+
6
+ const formatDescriptions: Record<string, string> = {
7
+ multiple_choice: `{
8
+ "format": "multiple_choice",
9
+ "passageText": "...",
10
+ "questionText": "...",
11
+ "options": [{"key": "A", "text": "..."}, {"key": "B", "text": "..."}, ...],
12
+ "correctAnswer": "A",
13
+ "explanation": "...",
14
+ "difficulty": ${difficulty},
15
+ "skillTags": ["..."]
16
+ }`,
17
+ true_false_not_given: `{
18
+ "format": "true_false_not_given",
19
+ "passageText": "...",
20
+ "questionText": "...",
21
+ "correctAnswer": "TRUE" | "FALSE" | "NOT_GIVEN",
22
+ "explanation": "...",
23
+ "difficulty": ${difficulty},
24
+ "skillTags": ["..."]
25
+ }`,
26
+ fill_blank: `{
27
+ "format": "fill_blank",
28
+ "passageText": "...",
29
+ "questionText": "Fill in the blank: ...",
30
+ "correctAnswer": "exact text",
31
+ "explanation": "...",
32
+ "difficulty": ${difficulty},
33
+ "skillTags": ["..."]
34
+ }`,
35
+ synonym: `{
36
+ "format": "synonym",
37
+ "passageText": "...",
38
+ "questionText": "The word '___' in the passage is closest in meaning to:",
39
+ "options": [{"key": "A", "text": "..."}, ...],
40
+ "correctAnswer": "A",
41
+ "explanation": "...",
42
+ "difficulty": ${difficulty},
43
+ "skillTags": ["vocabulary", "synonym"]
44
+ }`,
45
+ grammar_in_context: `{
46
+ "format": "grammar_in_context",
47
+ "passageText": "...",
48
+ "questionText": "...",
49
+ "options": [{"key": "A", "text": "..."}, ...],
50
+ "correctAnswer": "A",
51
+ "explanation": "...",
52
+ "difficulty": ${difficulty},
53
+ "skillTags": ["grammar"]
54
+ }`,
55
+ sentence_completion: `{
56
+ "format": "sentence_completion",
57
+ "passageText": "...",
58
+ "questionText": "...",
59
+ "options": [{"key": "A", "text": "..."}, ...],
60
+ "correctAnswer": "A",
61
+ "explanation": "...",
62
+ "difficulty": ${difficulty},
63
+ "skillTags": ["..."]
64
+ }`,
65
+ cloze: `{
66
+ "format": "cloze",
67
+ "passageText": "...",
68
+ "questionText": "Fill each blank with the correct option:",
69
+ "options": [{"key": "A", "text": "..."}, ...],
70
+ "correctAnswer": "serialized mapping of blank index to option key",
71
+ "explanation": "...",
72
+ "difficulty": ${difficulty},
73
+ "skillTags": ["grammar", "vocabulary"]
74
+ }`,
75
+ reference: `{
76
+ "format": "reference",
77
+ "passageText": "...",
78
+ "questionText": "The word 'it' in paragraph X refers to:",
79
+ "options": [{"key": "A", "text": "..."}, ...],
80
+ "correctAnswer": "A",
81
+ "explanation": "...",
82
+ "difficulty": ${difficulty},
83
+ "skillTags": ["reference", "inference"]
84
+ }`,
85
+ author_view: `{
86
+ "format": "author_view",
87
+ "passageText": "...",
88
+ "questionText": "...",
89
+ "correctAnswer": "YES" | "NO" | "NOT_GIVEN",
90
+ "explanation": "...",
91
+ "difficulty": ${difficulty},
92
+ "skillTags": ["inference", "author_view"]
93
+ }`,
94
+ matching_headings: `{
95
+ "format": "matching_headings",
96
+ "passageText": "...",
97
+ "questionText": "Match each paragraph to a heading:",
98
+ "options": [{"key": "i", "text": "..."}, ...],
99
+ "correctAnswer": "serialized mapping of paragraph to heading key",
100
+ "explanation": "...",
101
+ "difficulty": ${difficulty},
102
+ "skillTags": ["main_idea", "matching"]
103
+ }`,
104
+ kanji_reading: `{
105
+ "format": "kanji_reading",
106
+ "passageText": "...",
107
+ "questionText": "How is the kanji '...' read in this context?",
108
+ "options": [{"key": "A", "text": "..."}, ...],
109
+ "correctAnswer": "A",
110
+ "explanation": "...",
111
+ "difficulty": ${difficulty},
112
+ "skillTags": ["kanji", "reading"]
113
+ }`,
114
+ particle_choice: `{
115
+ "format": "particle_choice",
116
+ "passageText": "...",
117
+ "questionText": "Which particle fits the blank?",
118
+ "options": [{"key": "A", "text": "..."}, ...],
119
+ "correctAnswer": "A",
120
+ "explanation": "...",
121
+ "difficulty": ${difficulty},
122
+ "skillTags": ["grammar", "particle"]
123
+ }`,
124
+ article_case: `{
125
+ "format": "article_case",
126
+ "passageText": "...",
127
+ "questionText": "Which article/case fits the blank?",
128
+ "options": [{"key": "A", "text": "..."}, ...],
129
+ "correctAnswer": "A",
130
+ "explanation": "...",
131
+ "difficulty": ${difficulty},
132
+ "skillTags": ["grammar", "article", "case"]
133
+ }`,
134
+ };
135
+
136
+ const formatExamples = formats
137
+ .map((f) => formatDescriptions[f] || formatDescriptions["multiple_choice"])
138
+ .join("\n\n---\n\n");
139
+
140
+ return `You are an expert exam question writer for ${examType} ${section.toLowerCase()} section.
141
+
142
+ Generate ${questionCount} authentic, high-quality reading comprehension questions.
143
+
144
+ EXAM: ${examType}
145
+ SECTION: ${section}
146
+ DIFFICULTY: ${difficulty}/5
147
+ TOPICS: ${topics.join(", ")}
148
+
149
+ INSTRUCTIONS:
150
+ - The reading passage must be written in the target language of the exam (${examType === "JLPT" ? "Japanese" : examType === "HSK" ? "Chinese" : examType === "GOETHE" ? "German" : "English"}).
151
+ - Passage length should be appropriate for the exam type and difficulty.
152
+ - Each question must have:
153
+ * a reading passage (passageText)
154
+ * a clear question prompt (questionText)
155
+ * a correct answer (correctAnswer)
156
+ * an explanation (explanation)
157
+ * difficulty level (${difficulty})
158
+ * relevant skill tags (skillTags)
159
+ - Questions should test real comprehension, not just surface-level recall.
160
+ - For multiple choice: always provide 4 options labeled A, B, C, D.
161
+ - Options must be plausible distractors β€” one clearly correct answer.
162
+
163
+ OUTPUT FORMAT:
164
+ Return ONLY a valid JSON object with this exact structure (no markdown code blocks, no extra text):
165
+
166
+ {
167
+ "questions": [
168
+ ${formatExamples}
169
+ ]
170
+ }
171
+
172
+ You may reuse the same passage for multiple questions, or generate a new passage per question β€” whichever is more natural for the exam type.
173
+ `;
174
+ }
packages/ai/src/schemas.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+
3
+ // ── Shared Schemas ─────────────────────────────────────────
4
+
5
+ export const examTypeSchema = z.enum(["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"]);
6
+ export const sectionTypeSchema = z.enum(["READING", "WRITING", "LISTENING", "SPEAKING"]);
7
+
8
+ export const questionFormatSchema = z.enum([
9
+ "multiple_choice",
10
+ "true_false_not_given",
11
+ "matching_headings",
12
+ "matching_information",
13
+ "fill_blank",
14
+ "synonym",
15
+ "grammar_in_context",
16
+ "sentence_completion",
17
+ "summary_completion",
18
+ "cloze",
19
+ "reference",
20
+ "author_view",
21
+ "kanji_reading",
22
+ "particle_choice",
23
+ "article_case",
24
+ ]);
25
+
26
+ export const difficultySchema = z.number().int().min(1).max(5);
27
+
28
+ export const aiKeyConfigSchema = z.object({
29
+ baseUrl: z.string().url(),
30
+ apiKey: z.string().min(1),
31
+ model: z.string().min(1),
32
+ });
33
+
34
+ // ── Question Option Schemas ────────────────────────────────
35
+
36
+ export const multipleChoiceOptionSchema = z.object({
37
+ key: z.string(),
38
+ text: z.string(),
39
+ });
40
+
41
+ export const matchingPairSchema = z.object({
42
+ left: z.string(),
43
+ right: z.string(),
44
+ });
45
+
46
+ // ── Base Question Schema ───────────────────────────────────
47
+
48
+ export const baseQuestionSchema = z.object({
49
+ format: questionFormatSchema,
50
+ passageText: z.string().min(50),
51
+ questionText: z.string().min(10),
52
+ correctAnswer: z.string(),
53
+ explanation: z.string(),
54
+ difficulty: difficultySchema,
55
+ skillTags: z.array(z.string()).min(1),
56
+ });
57
+
58
+ // ── Format-Specific Question Schemas ───────────────────────
59
+
60
+ export const multipleChoiceQuestionSchema = baseQuestionSchema.extend({
61
+ format: z.literal("multiple_choice"),
62
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
63
+ correctAnswer: z.string(), // key of the correct option
64
+ });
65
+
66
+ export const trueFalseQuestionSchema = baseQuestionSchema.extend({
67
+ format: z.literal("true_false_not_given"),
68
+ correctAnswer: z.enum(["TRUE", "FALSE", "NOT_GIVEN"]),
69
+ });
70
+
71
+ export const matchingHeadingsQuestionSchema = baseQuestionSchema.extend({
72
+ format: z.literal("matching_headings"),
73
+ options: z.array(multipleChoiceOptionSchema), // headings to match
74
+ correctAnswer: z.string(), // serialized mapping
75
+ });
76
+
77
+ export const matchingInformationQuestionSchema = baseQuestionSchema.extend({
78
+ format: z.literal("matching_information"),
79
+ options: z.array(multipleChoiceOptionSchema),
80
+ correctAnswer: z.string(),
81
+ });
82
+
83
+ export const fillBlankQuestionSchema = baseQuestionSchema.extend({
84
+ format: z.literal("fill_blank"),
85
+ correctAnswer: z.string(), // exact text to fill
86
+ });
87
+
88
+ export const synonymQuestionSchema = baseQuestionSchema.extend({
89
+ format: z.literal("synonym"),
90
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
91
+ correctAnswer: z.string(), // key of synonym option
92
+ });
93
+
94
+ export const grammarInContextQuestionSchema = baseQuestionSchema.extend({
95
+ format: z.literal("grammar_in_context"),
96
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
97
+ correctAnswer: z.string(),
98
+ });
99
+
100
+ export const sentenceCompletionQuestionSchema = baseQuestionSchema.extend({
101
+ format: z.literal("sentence_completion"),
102
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
103
+ correctAnswer: z.string(),
104
+ });
105
+
106
+ export const summaryCompletionQuestionSchema = baseQuestionSchema.extend({
107
+ format: z.literal("summary_completion"),
108
+ options: z.array(multipleChoiceOptionSchema).min(2),
109
+ correctAnswer: z.string(), // serialized answers
110
+ });
111
+
112
+ export const clozeQuestionSchema = baseQuestionSchema.extend({
113
+ format: z.literal("cloze"),
114
+ options: z.array(multipleChoiceOptionSchema).min(2),
115
+ correctAnswer: z.string(), // serialized answers for each blank
116
+ });
117
+
118
+ export const referenceQuestionSchema = baseQuestionSchema.extend({
119
+ format: z.literal("reference"),
120
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
121
+ correctAnswer: z.string(), // key of correct reference
122
+ });
123
+
124
+ export const authorViewQuestionSchema = baseQuestionSchema.extend({
125
+ format: z.literal("author_view"),
126
+ correctAnswer: z.enum(["YES", "NO", "NOT_GIVEN"]),
127
+ });
128
+
129
+ export const kanjiReadingQuestionSchema = baseQuestionSchema.extend({
130
+ format: z.literal("kanji_reading"),
131
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
132
+ correctAnswer: z.string(),
133
+ });
134
+
135
+ export const particleChoiceQuestionSchema = baseQuestionSchema.extend({
136
+ format: z.literal("particle_choice"),
137
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
138
+ correctAnswer: z.string(),
139
+ });
140
+
141
+ export const articleCaseQuestionSchema = baseQuestionSchema.extend({
142
+ format: z.literal("article_case"),
143
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6),
144
+ correctAnswer: z.string(),
145
+ });
146
+
147
+ // ── Unified Question Schema ───────────────────���────────────
148
+
149
+ export const questionSchema = z.discriminatedUnion("format", [
150
+ multipleChoiceQuestionSchema,
151
+ trueFalseQuestionSchema,
152
+ matchingHeadingsQuestionSchema,
153
+ matchingInformationQuestionSchema,
154
+ fillBlankQuestionSchema,
155
+ synonymQuestionSchema,
156
+ grammarInContextQuestionSchema,
157
+ sentenceCompletionQuestionSchema,
158
+ summaryCompletionQuestionSchema,
159
+ clozeQuestionSchema,
160
+ referenceQuestionSchema,
161
+ authorViewQuestionSchema,
162
+ kanjiReadingQuestionSchema,
163
+ particleChoiceQuestionSchema,
164
+ articleCaseQuestionSchema,
165
+ ]);
166
+
167
+ export type Question = z.infer<typeof questionSchema>;
168
+
169
+ // ── Generation Input / Output ──────────────────────────────
170
+
171
+ export const generationInputSchema = z.object({
172
+ examType: examTypeSchema,
173
+ section: sectionTypeSchema,
174
+ formats: z.array(questionFormatSchema).min(1),
175
+ difficulty: difficultySchema,
176
+ topics: z.array(z.string()).min(1),
177
+ questionCount: z.number().int().min(1).max(20),
178
+ mode: z.enum(["quick", "agentic"]).default("quick"),
179
+ apiKeyConfig: aiKeyConfigSchema,
180
+ });
181
+
182
+ export type GenerationInput = z.infer<typeof generationInputSchema>;
183
+
184
+ export const generationResultSchema = z.object({
185
+ questions: z.array(questionSchema),
186
+ meta: z.object({
187
+ model: z.string(),
188
+ tokensUsed: z.number().optional(),
189
+ durationMs: z.number(),
190
+ mode: z.enum(["quick", "agentic"]),
191
+ }),
192
+ });
193
+
194
+ export type GenerationResult = z.infer<typeof generationResultSchema>;
packages/api/package.json CHANGED
@@ -11,12 +11,14 @@
11
  },
12
  "scripts": {},
13
  "dependencies": {
 
14
  "@labas/auth": "workspace:*",
15
  "@labas/db": "workspace:*",
16
  "@labas/env": "workspace:*",
17
  "@trpc/client": "catalog:",
18
  "@trpc/server": "catalog:",
19
  "dotenv": "catalog:",
 
20
  "zod": "catalog:"
21
  },
22
  "devDependencies": {
 
11
  },
12
  "scripts": {},
13
  "dependencies": {
14
+ "@labas/ai": "workspace:*",
15
  "@labas/auth": "workspace:*",
16
  "@labas/db": "workspace:*",
17
  "@labas/env": "workspace:*",
18
  "@trpc/client": "catalog:",
19
  "@trpc/server": "catalog:",
20
  "dotenv": "catalog:",
21
+ "drizzle-orm": "^0.45.1",
22
  "zod": "catalog:"
23
  },
24
  "devDependencies": {
packages/api/src/routers/ai.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { router, protectedProcedure } from "../index";
3
+ import { generateQuestionsQuick } from "@labas/ai";
4
+ import { generationInputSchema } from "@labas/ai/schemas";
5
+ import { db } from "@labas/db";
6
+ import { question } from "@labas/db";
7
+
8
+ export const aiRouter = router({
9
+ generate: protectedProcedure
10
+ .input(generationInputSchema)
11
+ .mutation(async ({ input }) => {
12
+ const result = await generateQuestionsQuick(input);
13
+ return result;
14
+ }),
15
+
16
+ saveQuestions: protectedProcedure
17
+ .input(
18
+ z.object({
19
+ examTypeId: z.string(),
20
+ sectionTypeId: z.string(),
21
+ questions: z.array(z.any()),
22
+ isPublic: z.boolean().default(false),
23
+ }),
24
+ )
25
+ .mutation(async ({ ctx, input }) => {
26
+ const created = await db
27
+ .insert(question)
28
+ .values(
29
+ input.questions.map((q: any) => ({
30
+ examTypeId: input.examTypeId,
31
+ sectionTypeId: input.sectionTypeId,
32
+ format: q.format,
33
+ passageText: q.passageText,
34
+ questionText: q.questionText,
35
+ options: q.options ?? null,
36
+ correctAnswer: q.correctAnswer,
37
+ explanation: q.explanation,
38
+ difficulty: q.difficulty,
39
+ skillTags: q.skillTags,
40
+ source: "ai" as const,
41
+ aiModel: q.aiModel ?? null,
42
+ creatorUserId: ctx.session.user.id,
43
+ isPublic: input.isPublic,
44
+ })),
45
+ )
46
+ .returning();
47
+
48
+ return created;
49
+ }),
50
+ });
packages/api/src/routers/index.ts CHANGED
@@ -1,4 +1,7 @@
1
  import { protectedProcedure, publicProcedure, router } from "../index";
 
 
 
2
 
3
  export const appRouter = router({
4
  healthCheck: publicProcedure.query(() => {
@@ -10,5 +13,9 @@ export const appRouter = router({
10
  user: ctx.session.user,
11
  };
12
  }),
 
 
 
13
  });
 
14
  export type AppRouter = typeof appRouter;
 
1
  import { protectedProcedure, publicProcedure, router } from "../index";
2
+ import { aiRouter } from "./ai";
3
+ import { questionRouter } from "./question";
4
+ import { settingsRouter } from "./settings";
5
 
6
  export const appRouter = router({
7
  healthCheck: publicProcedure.query(() => {
 
13
  user: ctx.session.user,
14
  };
15
  }),
16
+ ai: aiRouter,
17
+ question: questionRouter,
18
+ settings: settingsRouter,
19
  });
20
+
21
  export type AppRouter = typeof appRouter;
packages/api/src/routers/question.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { eq, and, desc } from "drizzle-orm";
3
+ import { router, protectedProcedure, publicProcedure } from "../index";
4
+ import { db } from "@labas/db";
5
+ import { question } from "@labas/db";
6
+
7
+ export const questionRouter = router({
8
+ list: publicProcedure
9
+ .input(
10
+ z
11
+ .object({
12
+ examTypeId: z.string().optional(),
13
+ sectionTypeId: z.string().optional(),
14
+ format: z.string().optional(),
15
+ difficulty: z.number().optional(),
16
+ isPublic: z.boolean().optional(),
17
+ search: z.string().optional(),
18
+ limit: z.number().min(1).max(50).default(20),
19
+ offset: z.number().min(0).default(0),
20
+ })
21
+ .optional(),
22
+ )
23
+ .query(async ({ ctx, input }) => {
24
+ const userId = ctx.session?.user.id;
25
+ const conditions = [];
26
+
27
+ if (input?.examTypeId) conditions.push(eq(question.examTypeId, input.examTypeId));
28
+ if (input?.sectionTypeId) conditions.push(eq(question.sectionTypeId, input.sectionTypeId));
29
+ if (input?.format) conditions.push(eq(question.format, input.format));
30
+ if (input?.difficulty) conditions.push(eq(question.difficulty, input.difficulty));
31
+
32
+ if (input?.isPublic !== undefined) {
33
+ conditions.push(eq(question.isPublic, input.isPublic));
34
+ } else if (!userId) {
35
+ conditions.push(eq(question.isPublic, true));
36
+ }
37
+
38
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
39
+
40
+ const rows = await db
41
+ .select()
42
+ .from(question)
43
+ .where(where)
44
+ .orderBy(desc(question.createdAt))
45
+ .limit(input?.limit ?? 20)
46
+ .offset(input?.offset ?? 0);
47
+
48
+ return rows;
49
+ }),
50
+
51
+ getById: publicProcedure
52
+ .input(z.object({ id: z.string().uuid() }))
53
+ .query(async ({ input }) => {
54
+ const [row] = await db
55
+ .select()
56
+ .from(question)
57
+ .where(eq(question.id, input.id))
58
+ .limit(1);
59
+ return row ?? null;
60
+ }),
61
+
62
+ create: protectedProcedure
63
+ .input(
64
+ z.object({
65
+ examTypeId: z.string(),
66
+ sectionTypeId: z.string(),
67
+ format: z.string(),
68
+ passageText: z.string().min(1),
69
+ questionText: z.string().min(1),
70
+ options: z.any().optional(),
71
+ correctAnswer: z.string(),
72
+ explanation: z.string().optional(),
73
+ difficulty: z.number().min(1).max(5).default(3),
74
+ skillTags: z.array(z.string()).default([]),
75
+ isPublic: z.boolean().default(false),
76
+ }),
77
+ )
78
+ .mutation(async ({ ctx, input }) => {
79
+ const [row] = await db
80
+ .insert(question)
81
+ .values({
82
+ ...input,
83
+ source: "manual",
84
+ creatorUserId: ctx.session.user.id,
85
+ })
86
+ .returning();
87
+ return row;
88
+ }),
89
+
90
+ delete: protectedProcedure
91
+ .input(z.object({ id: z.string().uuid() }))
92
+ .mutation(async ({ ctx, input }) => {
93
+ await db
94
+ .delete(question)
95
+ .where(and(eq(question.id, input.id), eq(question.creatorUserId, ctx.session.user.id)));
96
+ return { success: true };
97
+ }),
98
+ });
packages/api/src/routers/settings.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { router, protectedProcedure } from "../index";
2
+
3
+ export const settingsRouter = router({
4
+ // Placeholder for future settings endpoints (profile, preferences, etc.)
5
+ getProfile: protectedProcedure.query(({ ctx }) => {
6
+ return {
7
+ id: ctx.session.user.id,
8
+ name: ctx.session.user.name,
9
+ email: ctx.session.user.email,
10
+ image: ctx.session.user.image,
11
+ };
12
+ }),
13
+ });
packages/db/src/index.ts CHANGED
@@ -8,3 +8,4 @@ export function createDb() {
8
  }
9
 
10
  export const db = createDb();
 
 
8
  }
9
 
10
  export const db = createDb();
11
+ export * from "./schema";
packages/db/src/migrations/0000_amazing_gamora.sql ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE "answer" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "section_result_id" uuid NOT NULL,
4
+ "question_id" uuid NOT NULL,
5
+ "user_answer" text,
6
+ "is_correct" boolean,
7
+ "time_spent_sec" integer,
8
+ "created_at" timestamp DEFAULT now() NOT NULL,
9
+ CONSTRAINT "answer_unique_idx" UNIQUE("section_result_id","question_id")
10
+ );
11
+ --> statement-breakpoint
12
+ CREATE TABLE "combo_package" (
13
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
14
+ "title" text NOT NULL,
15
+ "description" text,
16
+ "creator_user_id" text NOT NULL,
17
+ "is_public" boolean DEFAULT false NOT NULL,
18
+ "created_at" timestamp DEFAULT now() NOT NULL,
19
+ "updated_at" timestamp DEFAULT now() NOT NULL
20
+ );
21
+ --> statement-breakpoint
22
+ CREATE TABLE "combo_section" (
23
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
24
+ "combo_id" uuid NOT NULL,
25
+ "source_package_id" uuid NOT NULL,
26
+ "source_section_id" uuid NOT NULL,
27
+ "order_index" integer DEFAULT 0 NOT NULL
28
+ );
29
+ --> statement-breakpoint
30
+ CREATE TABLE "exam_type" (
31
+ "id" text PRIMARY KEY NOT NULL,
32
+ "name" text NOT NULL,
33
+ "language" text NOT NULL,
34
+ "description" text
35
+ );
36
+ --> statement-breakpoint
37
+ CREATE TABLE "package_section" (
38
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
39
+ "package_id" uuid NOT NULL,
40
+ "section_type_id" text NOT NULL,
41
+ "title" text NOT NULL,
42
+ "order_index" integer DEFAULT 0 NOT NULL,
43
+ "created_at" timestamp DEFAULT now() NOT NULL
44
+ );
45
+ --> statement-breakpoint
46
+ CREATE TABLE "question" (
47
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
48
+ "exam_type_id" text NOT NULL,
49
+ "section_type_id" text NOT NULL,
50
+ "format" text NOT NULL,
51
+ "passage_text" text NOT NULL,
52
+ "question_text" text NOT NULL,
53
+ "options" jsonb,
54
+ "correct_answer" text NOT NULL,
55
+ "explanation" text,
56
+ "difficulty" integer DEFAULT 3 NOT NULL,
57
+ "skill_tags" text[] DEFAULT '{}',
58
+ "source" text DEFAULT 'manual' NOT NULL,
59
+ "ai_model" text,
60
+ "ai_prompt_used" text,
61
+ "creator_user_id" text NOT NULL,
62
+ "is_public" boolean DEFAULT false NOT NULL,
63
+ "usage_count" integer DEFAULT 0 NOT NULL,
64
+ "avg_rating" integer,
65
+ "created_at" timestamp DEFAULT now() NOT NULL,
66
+ "updated_at" timestamp DEFAULT now() NOT NULL
67
+ );
68
+ --> statement-breakpoint
69
+ CREATE TABLE "question_rating" (
70
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
71
+ "user_id" text NOT NULL,
72
+ "question_id" uuid NOT NULL,
73
+ "score" integer NOT NULL,
74
+ "created_at" timestamp DEFAULT now() NOT NULL,
75
+ CONSTRAINT "questionRating_unique_idx" UNIQUE("user_id","question_id")
76
+ );
77
+ --> statement-breakpoint
78
+ CREATE TABLE "section_question" (
79
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
80
+ "section_id" uuid NOT NULL,
81
+ "question_id" uuid NOT NULL,
82
+ "order_index" integer DEFAULT 0 NOT NULL,
83
+ CONSTRAINT "sectionQuestion_unique_idx" UNIQUE("section_id","question_id")
84
+ );
85
+ --> statement-breakpoint
86
+ CREATE TABLE "section_result" (
87
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
88
+ "attempt_id" uuid NOT NULL,
89
+ "section_type_id" text NOT NULL,
90
+ "score" integer,
91
+ "max_score" integer,
92
+ "time_spent_sec" integer,
93
+ "created_at" timestamp DEFAULT now() NOT NULL
94
+ );
95
+ --> statement-breakpoint
96
+ CREATE TABLE "section_type" (
97
+ "id" text PRIMARY KEY NOT NULL,
98
+ "name" text NOT NULL
99
+ );
100
+ --> statement-breakpoint
101
+ CREATE TABLE "test_attempt" (
102
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
103
+ "user_id" text NOT NULL,
104
+ "package_id" uuid,
105
+ "combo_id" uuid,
106
+ "started_at" timestamp DEFAULT now() NOT NULL,
107
+ "finished_at" timestamp,
108
+ "total_score" integer,
109
+ "max_score" integer,
110
+ "status" text DEFAULT 'in_progress' NOT NULL,
111
+ "created_at" timestamp DEFAULT now() NOT NULL
112
+ );
113
+ --> statement-breakpoint
114
+ CREATE TABLE "test_package" (
115
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
116
+ "title" text NOT NULL,
117
+ "description" text,
118
+ "exam_type_id" text NOT NULL,
119
+ "creator_user_id" text NOT NULL,
120
+ "is_public" boolean DEFAULT false NOT NULL,
121
+ "total_questions" integer DEFAULT 0 NOT NULL,
122
+ "total_sections" integer DEFAULT 0 NOT NULL,
123
+ "estimated_duration_min" integer,
124
+ "usage_count" integer DEFAULT 0 NOT NULL,
125
+ "avg_rating" integer,
126
+ "created_at" timestamp DEFAULT now() NOT NULL,
127
+ "updated_at" timestamp DEFAULT now() NOT NULL
128
+ );
129
+ --> statement-breakpoint
130
+ CREATE TABLE "user_api_key" (
131
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
132
+ "user_id" text NOT NULL,
133
+ "provider" text DEFAULT 'openai' NOT NULL,
134
+ "base_url" text,
135
+ "api_key_encrypted" text NOT NULL,
136
+ "model_name" text DEFAULT 'gpt-4o-mini' NOT NULL,
137
+ "is_active" boolean DEFAULT true NOT NULL,
138
+ "created_at" timestamp DEFAULT now() NOT NULL,
139
+ "updated_at" timestamp DEFAULT now() NOT NULL
140
+ );
141
+ --> statement-breakpoint
142
+ CREATE TABLE "account" (
143
+ "id" text PRIMARY KEY NOT NULL,
144
+ "account_id" text NOT NULL,
145
+ "provider_id" text NOT NULL,
146
+ "user_id" text NOT NULL,
147
+ "access_token" text,
148
+ "refresh_token" text,
149
+ "id_token" text,
150
+ "access_token_expires_at" timestamp,
151
+ "refresh_token_expires_at" timestamp,
152
+ "scope" text,
153
+ "password" text,
154
+ "created_at" timestamp DEFAULT now() NOT NULL,
155
+ "updated_at" timestamp NOT NULL
156
+ );
157
+ --> statement-breakpoint
158
+ CREATE TABLE "session" (
159
+ "id" text PRIMARY KEY NOT NULL,
160
+ "expires_at" timestamp NOT NULL,
161
+ "token" text NOT NULL,
162
+ "created_at" timestamp DEFAULT now() NOT NULL,
163
+ "updated_at" timestamp NOT NULL,
164
+ "ip_address" text,
165
+ "user_agent" text,
166
+ "user_id" text NOT NULL,
167
+ CONSTRAINT "session_token_unique" UNIQUE("token")
168
+ );
169
+ --> statement-breakpoint
170
+ CREATE TABLE "user" (
171
+ "id" text PRIMARY KEY NOT NULL,
172
+ "name" text NOT NULL,
173
+ "email" text NOT NULL,
174
+ "email_verified" boolean DEFAULT false NOT NULL,
175
+ "image" text,
176
+ "created_at" timestamp DEFAULT now() NOT NULL,
177
+ "updated_at" timestamp DEFAULT now() NOT NULL,
178
+ CONSTRAINT "user_email_unique" UNIQUE("email")
179
+ );
180
+ --> statement-breakpoint
181
+ CREATE TABLE "verification" (
182
+ "id" text PRIMARY KEY NOT NULL,
183
+ "identifier" text NOT NULL,
184
+ "value" text NOT NULL,
185
+ "expires_at" timestamp NOT NULL,
186
+ "created_at" timestamp DEFAULT now() NOT NULL,
187
+ "updated_at" timestamp DEFAULT now() NOT NULL
188
+ );
189
+ --> statement-breakpoint
190
+ ALTER TABLE "answer" ADD CONSTRAINT "answer_section_result_id_section_result_id_fk" FOREIGN KEY ("section_result_id") REFERENCES "public"."section_result"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
191
+ ALTER TABLE "answer" ADD CONSTRAINT "answer_question_id_question_id_fk" FOREIGN KEY ("question_id") REFERENCES "public"."question"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
192
+ ALTER TABLE "combo_package" ADD CONSTRAINT "combo_package_creator_user_id_user_id_fk" FOREIGN KEY ("creator_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
193
+ ALTER TABLE "combo_section" ADD CONSTRAINT "combo_section_combo_id_combo_package_id_fk" FOREIGN KEY ("combo_id") REFERENCES "public"."combo_package"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
194
+ ALTER TABLE "combo_section" ADD CONSTRAINT "combo_section_source_package_id_test_package_id_fk" FOREIGN KEY ("source_package_id") REFERENCES "public"."test_package"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
195
+ ALTER TABLE "combo_section" ADD CONSTRAINT "combo_section_source_section_id_package_section_id_fk" FOREIGN KEY ("source_section_id") REFERENCES "public"."package_section"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
196
+ ALTER TABLE "package_section" ADD CONSTRAINT "package_section_package_id_test_package_id_fk" FOREIGN KEY ("package_id") REFERENCES "public"."test_package"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
197
+ ALTER TABLE "package_section" ADD CONSTRAINT "package_section_section_type_id_section_type_id_fk" FOREIGN KEY ("section_type_id") REFERENCES "public"."section_type"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
198
+ ALTER TABLE "question" ADD CONSTRAINT "question_exam_type_id_exam_type_id_fk" FOREIGN KEY ("exam_type_id") REFERENCES "public"."exam_type"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
199
+ ALTER TABLE "question" ADD CONSTRAINT "question_section_type_id_section_type_id_fk" FOREIGN KEY ("section_type_id") REFERENCES "public"."section_type"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
200
+ ALTER TABLE "question" ADD CONSTRAINT "question_creator_user_id_user_id_fk" FOREIGN KEY ("creator_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
201
+ ALTER TABLE "question_rating" ADD CONSTRAINT "question_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
202
+ ALTER TABLE "question_rating" ADD CONSTRAINT "question_rating_question_id_question_id_fk" FOREIGN KEY ("question_id") REFERENCES "public"."question"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
203
+ ALTER TABLE "section_question" ADD CONSTRAINT "section_question_section_id_package_section_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."package_section"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
204
+ ALTER TABLE "section_question" ADD CONSTRAINT "section_question_question_id_question_id_fk" FOREIGN KEY ("question_id") REFERENCES "public"."question"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
205
+ ALTER TABLE "section_result" ADD CONSTRAINT "section_result_attempt_id_test_attempt_id_fk" FOREIGN KEY ("attempt_id") REFERENCES "public"."test_attempt"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
206
+ ALTER TABLE "section_result" ADD CONSTRAINT "section_result_section_type_id_section_type_id_fk" FOREIGN KEY ("section_type_id") REFERENCES "public"."section_type"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
207
+ ALTER TABLE "test_attempt" ADD CONSTRAINT "test_attempt_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
208
+ ALTER TABLE "test_attempt" ADD CONSTRAINT "test_attempt_package_id_test_package_id_fk" FOREIGN KEY ("package_id") REFERENCES "public"."test_package"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
209
+ ALTER TABLE "test_attempt" ADD CONSTRAINT "test_attempt_combo_id_combo_package_id_fk" FOREIGN KEY ("combo_id") REFERENCES "public"."combo_package"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
210
+ ALTER TABLE "test_package" ADD CONSTRAINT "test_package_exam_type_id_exam_type_id_fk" FOREIGN KEY ("exam_type_id") REFERENCES "public"."exam_type"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
211
+ ALTER TABLE "test_package" ADD CONSTRAINT "test_package_creator_user_id_user_id_fk" FOREIGN KEY ("creator_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
212
+ ALTER TABLE "user_api_key" ADD CONSTRAINT "user_api_key_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
213
+ ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
214
+ ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
215
+ CREATE INDEX "answer_questionId_idx" ON "answer" USING btree ("question_id");--> statement-breakpoint
216
+ CREATE INDEX "comboPackage_creatorUserId_idx" ON "combo_package" USING btree ("creator_user_id");--> statement-breakpoint
217
+ CREATE INDEX "comboPackage_isPublic_idx" ON "combo_package" USING btree ("is_public");--> statement-breakpoint
218
+ CREATE INDEX "comboSection_comboId_idx" ON "combo_section" USING btree ("combo_id");--> statement-breakpoint
219
+ CREATE INDEX "comboSection_sourcePackageId_idx" ON "combo_section" USING btree ("source_package_id");--> statement-breakpoint
220
+ CREATE INDEX "packageSection_packageId_idx" ON "package_section" USING btree ("package_id");--> statement-breakpoint
221
+ CREATE INDEX "packageSection_sectionTypeId_idx" ON "package_section" USING btree ("section_type_id");--> statement-breakpoint
222
+ CREATE INDEX "question_examTypeId_idx" ON "question" USING btree ("exam_type_id");--> statement-breakpoint
223
+ CREATE INDEX "question_sectionTypeId_idx" ON "question" USING btree ("section_type_id");--> statement-breakpoint
224
+ CREATE INDEX "question_creatorUserId_idx" ON "question" USING btree ("creator_user_id");--> statement-breakpoint
225
+ CREATE INDEX "question_isPublic_idx" ON "question" USING btree ("is_public");--> statement-breakpoint
226
+ CREATE INDEX "question_format_idx" ON "question" USING btree ("format");--> statement-breakpoint
227
+ CREATE INDEX "question_difficulty_idx" ON "question" USING btree ("difficulty");--> statement-breakpoint
228
+ CREATE INDEX "questionRating_questionId_idx" ON "question_rating" USING btree ("question_id");--> statement-breakpoint
229
+ CREATE INDEX "sectionQuestion_sectionId_idx" ON "section_question" USING btree ("section_id");--> statement-breakpoint
230
+ CREATE INDEX "sectionResult_attemptId_idx" ON "section_result" USING btree ("attempt_id");--> statement-breakpoint
231
+ CREATE INDEX "testAttempt_userId_idx" ON "test_attempt" USING btree ("user_id");--> statement-breakpoint
232
+ CREATE INDEX "testAttempt_packageId_idx" ON "test_attempt" USING btree ("package_id");--> statement-breakpoint
233
+ CREATE INDEX "testAttempt_status_idx" ON "test_attempt" USING btree ("status");--> statement-breakpoint
234
+ CREATE INDEX "testPackage_examTypeId_idx" ON "test_package" USING btree ("exam_type_id");--> statement-breakpoint
235
+ CREATE INDEX "testPackage_creatorUserId_idx" ON "test_package" USING btree ("creator_user_id");--> statement-breakpoint
236
+ CREATE INDEX "testPackage_isPublic_idx" ON "test_package" USING btree ("is_public");--> statement-breakpoint
237
+ CREATE INDEX "userApiKey_userId_idx" ON "user_api_key" USING btree ("user_id");--> statement-breakpoint
238
+ CREATE INDEX "userApiKey_isActive_idx" ON "user_api_key" USING btree ("is_active");--> statement-breakpoint
239
+ CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
240
+ CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
241
+ CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
packages/db/src/migrations/meta/0000_snapshot.json ADDED
@@ -0,0 +1,1887 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "96df5852-cd4e-4a9d-b27a-450f69a08263",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.answer": {
8
+ "name": "answer",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "gen_random_uuid()"
17
+ },
18
+ "section_result_id": {
19
+ "name": "section_result_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "question_id": {
25
+ "name": "question_id",
26
+ "type": "uuid",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "user_answer": {
31
+ "name": "user_answer",
32
+ "type": "text",
33
+ "primaryKey": false,
34
+ "notNull": false
35
+ },
36
+ "is_correct": {
37
+ "name": "is_correct",
38
+ "type": "boolean",
39
+ "primaryKey": false,
40
+ "notNull": false
41
+ },
42
+ "time_spent_sec": {
43
+ "name": "time_spent_sec",
44
+ "type": "integer",
45
+ "primaryKey": false,
46
+ "notNull": false
47
+ },
48
+ "created_at": {
49
+ "name": "created_at",
50
+ "type": "timestamp",
51
+ "primaryKey": false,
52
+ "notNull": true,
53
+ "default": "now()"
54
+ }
55
+ },
56
+ "indexes": {
57
+ "answer_questionId_idx": {
58
+ "name": "answer_questionId_idx",
59
+ "columns": [
60
+ {
61
+ "expression": "question_id",
62
+ "isExpression": false,
63
+ "asc": true,
64
+ "nulls": "last"
65
+ }
66
+ ],
67
+ "isUnique": false,
68
+ "concurrently": false,
69
+ "method": "btree",
70
+ "with": {}
71
+ }
72
+ },
73
+ "foreignKeys": {
74
+ "answer_section_result_id_section_result_id_fk": {
75
+ "name": "answer_section_result_id_section_result_id_fk",
76
+ "tableFrom": "answer",
77
+ "tableTo": "section_result",
78
+ "columnsFrom": [
79
+ "section_result_id"
80
+ ],
81
+ "columnsTo": [
82
+ "id"
83
+ ],
84
+ "onDelete": "cascade",
85
+ "onUpdate": "no action"
86
+ },
87
+ "answer_question_id_question_id_fk": {
88
+ "name": "answer_question_id_question_id_fk",
89
+ "tableFrom": "answer",
90
+ "tableTo": "question",
91
+ "columnsFrom": [
92
+ "question_id"
93
+ ],
94
+ "columnsTo": [
95
+ "id"
96
+ ],
97
+ "onDelete": "cascade",
98
+ "onUpdate": "no action"
99
+ }
100
+ },
101
+ "compositePrimaryKeys": {},
102
+ "uniqueConstraints": {
103
+ "answer_unique_idx": {
104
+ "name": "answer_unique_idx",
105
+ "nullsNotDistinct": false,
106
+ "columns": [
107
+ "section_result_id",
108
+ "question_id"
109
+ ]
110
+ }
111
+ },
112
+ "policies": {},
113
+ "checkConstraints": {},
114
+ "isRLSEnabled": false
115
+ },
116
+ "public.combo_package": {
117
+ "name": "combo_package",
118
+ "schema": "",
119
+ "columns": {
120
+ "id": {
121
+ "name": "id",
122
+ "type": "uuid",
123
+ "primaryKey": true,
124
+ "notNull": true,
125
+ "default": "gen_random_uuid()"
126
+ },
127
+ "title": {
128
+ "name": "title",
129
+ "type": "text",
130
+ "primaryKey": false,
131
+ "notNull": true
132
+ },
133
+ "description": {
134
+ "name": "description",
135
+ "type": "text",
136
+ "primaryKey": false,
137
+ "notNull": false
138
+ },
139
+ "creator_user_id": {
140
+ "name": "creator_user_id",
141
+ "type": "text",
142
+ "primaryKey": false,
143
+ "notNull": true
144
+ },
145
+ "is_public": {
146
+ "name": "is_public",
147
+ "type": "boolean",
148
+ "primaryKey": false,
149
+ "notNull": true,
150
+ "default": false
151
+ },
152
+ "created_at": {
153
+ "name": "created_at",
154
+ "type": "timestamp",
155
+ "primaryKey": false,
156
+ "notNull": true,
157
+ "default": "now()"
158
+ },
159
+ "updated_at": {
160
+ "name": "updated_at",
161
+ "type": "timestamp",
162
+ "primaryKey": false,
163
+ "notNull": true,
164
+ "default": "now()"
165
+ }
166
+ },
167
+ "indexes": {
168
+ "comboPackage_creatorUserId_idx": {
169
+ "name": "comboPackage_creatorUserId_idx",
170
+ "columns": [
171
+ {
172
+ "expression": "creator_user_id",
173
+ "isExpression": false,
174
+ "asc": true,
175
+ "nulls": "last"
176
+ }
177
+ ],
178
+ "isUnique": false,
179
+ "concurrently": false,
180
+ "method": "btree",
181
+ "with": {}
182
+ },
183
+ "comboPackage_isPublic_idx": {
184
+ "name": "comboPackage_isPublic_idx",
185
+ "columns": [
186
+ {
187
+ "expression": "is_public",
188
+ "isExpression": false,
189
+ "asc": true,
190
+ "nulls": "last"
191
+ }
192
+ ],
193
+ "isUnique": false,
194
+ "concurrently": false,
195
+ "method": "btree",
196
+ "with": {}
197
+ }
198
+ },
199
+ "foreignKeys": {
200
+ "combo_package_creator_user_id_user_id_fk": {
201
+ "name": "combo_package_creator_user_id_user_id_fk",
202
+ "tableFrom": "combo_package",
203
+ "tableTo": "user",
204
+ "columnsFrom": [
205
+ "creator_user_id"
206
+ ],
207
+ "columnsTo": [
208
+ "id"
209
+ ],
210
+ "onDelete": "cascade",
211
+ "onUpdate": "no action"
212
+ }
213
+ },
214
+ "compositePrimaryKeys": {},
215
+ "uniqueConstraints": {},
216
+ "policies": {},
217
+ "checkConstraints": {},
218
+ "isRLSEnabled": false
219
+ },
220
+ "public.combo_section": {
221
+ "name": "combo_section",
222
+ "schema": "",
223
+ "columns": {
224
+ "id": {
225
+ "name": "id",
226
+ "type": "uuid",
227
+ "primaryKey": true,
228
+ "notNull": true,
229
+ "default": "gen_random_uuid()"
230
+ },
231
+ "combo_id": {
232
+ "name": "combo_id",
233
+ "type": "uuid",
234
+ "primaryKey": false,
235
+ "notNull": true
236
+ },
237
+ "source_package_id": {
238
+ "name": "source_package_id",
239
+ "type": "uuid",
240
+ "primaryKey": false,
241
+ "notNull": true
242
+ },
243
+ "source_section_id": {
244
+ "name": "source_section_id",
245
+ "type": "uuid",
246
+ "primaryKey": false,
247
+ "notNull": true
248
+ },
249
+ "order_index": {
250
+ "name": "order_index",
251
+ "type": "integer",
252
+ "primaryKey": false,
253
+ "notNull": true,
254
+ "default": 0
255
+ }
256
+ },
257
+ "indexes": {
258
+ "comboSection_comboId_idx": {
259
+ "name": "comboSection_comboId_idx",
260
+ "columns": [
261
+ {
262
+ "expression": "combo_id",
263
+ "isExpression": false,
264
+ "asc": true,
265
+ "nulls": "last"
266
+ }
267
+ ],
268
+ "isUnique": false,
269
+ "concurrently": false,
270
+ "method": "btree",
271
+ "with": {}
272
+ },
273
+ "comboSection_sourcePackageId_idx": {
274
+ "name": "comboSection_sourcePackageId_idx",
275
+ "columns": [
276
+ {
277
+ "expression": "source_package_id",
278
+ "isExpression": false,
279
+ "asc": true,
280
+ "nulls": "last"
281
+ }
282
+ ],
283
+ "isUnique": false,
284
+ "concurrently": false,
285
+ "method": "btree",
286
+ "with": {}
287
+ }
288
+ },
289
+ "foreignKeys": {
290
+ "combo_section_combo_id_combo_package_id_fk": {
291
+ "name": "combo_section_combo_id_combo_package_id_fk",
292
+ "tableFrom": "combo_section",
293
+ "tableTo": "combo_package",
294
+ "columnsFrom": [
295
+ "combo_id"
296
+ ],
297
+ "columnsTo": [
298
+ "id"
299
+ ],
300
+ "onDelete": "cascade",
301
+ "onUpdate": "no action"
302
+ },
303
+ "combo_section_source_package_id_test_package_id_fk": {
304
+ "name": "combo_section_source_package_id_test_package_id_fk",
305
+ "tableFrom": "combo_section",
306
+ "tableTo": "test_package",
307
+ "columnsFrom": [
308
+ "source_package_id"
309
+ ],
310
+ "columnsTo": [
311
+ "id"
312
+ ],
313
+ "onDelete": "cascade",
314
+ "onUpdate": "no action"
315
+ },
316
+ "combo_section_source_section_id_package_section_id_fk": {
317
+ "name": "combo_section_source_section_id_package_section_id_fk",
318
+ "tableFrom": "combo_section",
319
+ "tableTo": "package_section",
320
+ "columnsFrom": [
321
+ "source_section_id"
322
+ ],
323
+ "columnsTo": [
324
+ "id"
325
+ ],
326
+ "onDelete": "cascade",
327
+ "onUpdate": "no action"
328
+ }
329
+ },
330
+ "compositePrimaryKeys": {},
331
+ "uniqueConstraints": {},
332
+ "policies": {},
333
+ "checkConstraints": {},
334
+ "isRLSEnabled": false
335
+ },
336
+ "public.exam_type": {
337
+ "name": "exam_type",
338
+ "schema": "",
339
+ "columns": {
340
+ "id": {
341
+ "name": "id",
342
+ "type": "text",
343
+ "primaryKey": true,
344
+ "notNull": true
345
+ },
346
+ "name": {
347
+ "name": "name",
348
+ "type": "text",
349
+ "primaryKey": false,
350
+ "notNull": true
351
+ },
352
+ "language": {
353
+ "name": "language",
354
+ "type": "text",
355
+ "primaryKey": false,
356
+ "notNull": true
357
+ },
358
+ "description": {
359
+ "name": "description",
360
+ "type": "text",
361
+ "primaryKey": false,
362
+ "notNull": false
363
+ }
364
+ },
365
+ "indexes": {},
366
+ "foreignKeys": {},
367
+ "compositePrimaryKeys": {},
368
+ "uniqueConstraints": {},
369
+ "policies": {},
370
+ "checkConstraints": {},
371
+ "isRLSEnabled": false
372
+ },
373
+ "public.package_section": {
374
+ "name": "package_section",
375
+ "schema": "",
376
+ "columns": {
377
+ "id": {
378
+ "name": "id",
379
+ "type": "uuid",
380
+ "primaryKey": true,
381
+ "notNull": true,
382
+ "default": "gen_random_uuid()"
383
+ },
384
+ "package_id": {
385
+ "name": "package_id",
386
+ "type": "uuid",
387
+ "primaryKey": false,
388
+ "notNull": true
389
+ },
390
+ "section_type_id": {
391
+ "name": "section_type_id",
392
+ "type": "text",
393
+ "primaryKey": false,
394
+ "notNull": true
395
+ },
396
+ "title": {
397
+ "name": "title",
398
+ "type": "text",
399
+ "primaryKey": false,
400
+ "notNull": true
401
+ },
402
+ "order_index": {
403
+ "name": "order_index",
404
+ "type": "integer",
405
+ "primaryKey": false,
406
+ "notNull": true,
407
+ "default": 0
408
+ },
409
+ "created_at": {
410
+ "name": "created_at",
411
+ "type": "timestamp",
412
+ "primaryKey": false,
413
+ "notNull": true,
414
+ "default": "now()"
415
+ }
416
+ },
417
+ "indexes": {
418
+ "packageSection_packageId_idx": {
419
+ "name": "packageSection_packageId_idx",
420
+ "columns": [
421
+ {
422
+ "expression": "package_id",
423
+ "isExpression": false,
424
+ "asc": true,
425
+ "nulls": "last"
426
+ }
427
+ ],
428
+ "isUnique": false,
429
+ "concurrently": false,
430
+ "method": "btree",
431
+ "with": {}
432
+ },
433
+ "packageSection_sectionTypeId_idx": {
434
+ "name": "packageSection_sectionTypeId_idx",
435
+ "columns": [
436
+ {
437
+ "expression": "section_type_id",
438
+ "isExpression": false,
439
+ "asc": true,
440
+ "nulls": "last"
441
+ }
442
+ ],
443
+ "isUnique": false,
444
+ "concurrently": false,
445
+ "method": "btree",
446
+ "with": {}
447
+ }
448
+ },
449
+ "foreignKeys": {
450
+ "package_section_package_id_test_package_id_fk": {
451
+ "name": "package_section_package_id_test_package_id_fk",
452
+ "tableFrom": "package_section",
453
+ "tableTo": "test_package",
454
+ "columnsFrom": [
455
+ "package_id"
456
+ ],
457
+ "columnsTo": [
458
+ "id"
459
+ ],
460
+ "onDelete": "cascade",
461
+ "onUpdate": "no action"
462
+ },
463
+ "package_section_section_type_id_section_type_id_fk": {
464
+ "name": "package_section_section_type_id_section_type_id_fk",
465
+ "tableFrom": "package_section",
466
+ "tableTo": "section_type",
467
+ "columnsFrom": [
468
+ "section_type_id"
469
+ ],
470
+ "columnsTo": [
471
+ "id"
472
+ ],
473
+ "onDelete": "cascade",
474
+ "onUpdate": "no action"
475
+ }
476
+ },
477
+ "compositePrimaryKeys": {},
478
+ "uniqueConstraints": {},
479
+ "policies": {},
480
+ "checkConstraints": {},
481
+ "isRLSEnabled": false
482
+ },
483
+ "public.question": {
484
+ "name": "question",
485
+ "schema": "",
486
+ "columns": {
487
+ "id": {
488
+ "name": "id",
489
+ "type": "uuid",
490
+ "primaryKey": true,
491
+ "notNull": true,
492
+ "default": "gen_random_uuid()"
493
+ },
494
+ "exam_type_id": {
495
+ "name": "exam_type_id",
496
+ "type": "text",
497
+ "primaryKey": false,
498
+ "notNull": true
499
+ },
500
+ "section_type_id": {
501
+ "name": "section_type_id",
502
+ "type": "text",
503
+ "primaryKey": false,
504
+ "notNull": true
505
+ },
506
+ "format": {
507
+ "name": "format",
508
+ "type": "text",
509
+ "primaryKey": false,
510
+ "notNull": true
511
+ },
512
+ "passage_text": {
513
+ "name": "passage_text",
514
+ "type": "text",
515
+ "primaryKey": false,
516
+ "notNull": true
517
+ },
518
+ "question_text": {
519
+ "name": "question_text",
520
+ "type": "text",
521
+ "primaryKey": false,
522
+ "notNull": true
523
+ },
524
+ "options": {
525
+ "name": "options",
526
+ "type": "jsonb",
527
+ "primaryKey": false,
528
+ "notNull": false
529
+ },
530
+ "correct_answer": {
531
+ "name": "correct_answer",
532
+ "type": "text",
533
+ "primaryKey": false,
534
+ "notNull": true
535
+ },
536
+ "explanation": {
537
+ "name": "explanation",
538
+ "type": "text",
539
+ "primaryKey": false,
540
+ "notNull": false
541
+ },
542
+ "difficulty": {
543
+ "name": "difficulty",
544
+ "type": "integer",
545
+ "primaryKey": false,
546
+ "notNull": true,
547
+ "default": 3
548
+ },
549
+ "skill_tags": {
550
+ "name": "skill_tags",
551
+ "type": "text[]",
552
+ "primaryKey": false,
553
+ "notNull": false,
554
+ "default": "'{}'"
555
+ },
556
+ "source": {
557
+ "name": "source",
558
+ "type": "text",
559
+ "primaryKey": false,
560
+ "notNull": true,
561
+ "default": "'manual'"
562
+ },
563
+ "ai_model": {
564
+ "name": "ai_model",
565
+ "type": "text",
566
+ "primaryKey": false,
567
+ "notNull": false
568
+ },
569
+ "ai_prompt_used": {
570
+ "name": "ai_prompt_used",
571
+ "type": "text",
572
+ "primaryKey": false,
573
+ "notNull": false
574
+ },
575
+ "creator_user_id": {
576
+ "name": "creator_user_id",
577
+ "type": "text",
578
+ "primaryKey": false,
579
+ "notNull": true
580
+ },
581
+ "is_public": {
582
+ "name": "is_public",
583
+ "type": "boolean",
584
+ "primaryKey": false,
585
+ "notNull": true,
586
+ "default": false
587
+ },
588
+ "usage_count": {
589
+ "name": "usage_count",
590
+ "type": "integer",
591
+ "primaryKey": false,
592
+ "notNull": true,
593
+ "default": 0
594
+ },
595
+ "avg_rating": {
596
+ "name": "avg_rating",
597
+ "type": "integer",
598
+ "primaryKey": false,
599
+ "notNull": false
600
+ },
601
+ "created_at": {
602
+ "name": "created_at",
603
+ "type": "timestamp",
604
+ "primaryKey": false,
605
+ "notNull": true,
606
+ "default": "now()"
607
+ },
608
+ "updated_at": {
609
+ "name": "updated_at",
610
+ "type": "timestamp",
611
+ "primaryKey": false,
612
+ "notNull": true,
613
+ "default": "now()"
614
+ }
615
+ },
616
+ "indexes": {
617
+ "question_examTypeId_idx": {
618
+ "name": "question_examTypeId_idx",
619
+ "columns": [
620
+ {
621
+ "expression": "exam_type_id",
622
+ "isExpression": false,
623
+ "asc": true,
624
+ "nulls": "last"
625
+ }
626
+ ],
627
+ "isUnique": false,
628
+ "concurrently": false,
629
+ "method": "btree",
630
+ "with": {}
631
+ },
632
+ "question_sectionTypeId_idx": {
633
+ "name": "question_sectionTypeId_idx",
634
+ "columns": [
635
+ {
636
+ "expression": "section_type_id",
637
+ "isExpression": false,
638
+ "asc": true,
639
+ "nulls": "last"
640
+ }
641
+ ],
642
+ "isUnique": false,
643
+ "concurrently": false,
644
+ "method": "btree",
645
+ "with": {}
646
+ },
647
+ "question_creatorUserId_idx": {
648
+ "name": "question_creatorUserId_idx",
649
+ "columns": [
650
+ {
651
+ "expression": "creator_user_id",
652
+ "isExpression": false,
653
+ "asc": true,
654
+ "nulls": "last"
655
+ }
656
+ ],
657
+ "isUnique": false,
658
+ "concurrently": false,
659
+ "method": "btree",
660
+ "with": {}
661
+ },
662
+ "question_isPublic_idx": {
663
+ "name": "question_isPublic_idx",
664
+ "columns": [
665
+ {
666
+ "expression": "is_public",
667
+ "isExpression": false,
668
+ "asc": true,
669
+ "nulls": "last"
670
+ }
671
+ ],
672
+ "isUnique": false,
673
+ "concurrently": false,
674
+ "method": "btree",
675
+ "with": {}
676
+ },
677
+ "question_format_idx": {
678
+ "name": "question_format_idx",
679
+ "columns": [
680
+ {
681
+ "expression": "format",
682
+ "isExpression": false,
683
+ "asc": true,
684
+ "nulls": "last"
685
+ }
686
+ ],
687
+ "isUnique": false,
688
+ "concurrently": false,
689
+ "method": "btree",
690
+ "with": {}
691
+ },
692
+ "question_difficulty_idx": {
693
+ "name": "question_difficulty_idx",
694
+ "columns": [
695
+ {
696
+ "expression": "difficulty",
697
+ "isExpression": false,
698
+ "asc": true,
699
+ "nulls": "last"
700
+ }
701
+ ],
702
+ "isUnique": false,
703
+ "concurrently": false,
704
+ "method": "btree",
705
+ "with": {}
706
+ }
707
+ },
708
+ "foreignKeys": {
709
+ "question_exam_type_id_exam_type_id_fk": {
710
+ "name": "question_exam_type_id_exam_type_id_fk",
711
+ "tableFrom": "question",
712
+ "tableTo": "exam_type",
713
+ "columnsFrom": [
714
+ "exam_type_id"
715
+ ],
716
+ "columnsTo": [
717
+ "id"
718
+ ],
719
+ "onDelete": "cascade",
720
+ "onUpdate": "no action"
721
+ },
722
+ "question_section_type_id_section_type_id_fk": {
723
+ "name": "question_section_type_id_section_type_id_fk",
724
+ "tableFrom": "question",
725
+ "tableTo": "section_type",
726
+ "columnsFrom": [
727
+ "section_type_id"
728
+ ],
729
+ "columnsTo": [
730
+ "id"
731
+ ],
732
+ "onDelete": "cascade",
733
+ "onUpdate": "no action"
734
+ },
735
+ "question_creator_user_id_user_id_fk": {
736
+ "name": "question_creator_user_id_user_id_fk",
737
+ "tableFrom": "question",
738
+ "tableTo": "user",
739
+ "columnsFrom": [
740
+ "creator_user_id"
741
+ ],
742
+ "columnsTo": [
743
+ "id"
744
+ ],
745
+ "onDelete": "cascade",
746
+ "onUpdate": "no action"
747
+ }
748
+ },
749
+ "compositePrimaryKeys": {},
750
+ "uniqueConstraints": {},
751
+ "policies": {},
752
+ "checkConstraints": {},
753
+ "isRLSEnabled": false
754
+ },
755
+ "public.question_rating": {
756
+ "name": "question_rating",
757
+ "schema": "",
758
+ "columns": {
759
+ "id": {
760
+ "name": "id",
761
+ "type": "uuid",
762
+ "primaryKey": true,
763
+ "notNull": true,
764
+ "default": "gen_random_uuid()"
765
+ },
766
+ "user_id": {
767
+ "name": "user_id",
768
+ "type": "text",
769
+ "primaryKey": false,
770
+ "notNull": true
771
+ },
772
+ "question_id": {
773
+ "name": "question_id",
774
+ "type": "uuid",
775
+ "primaryKey": false,
776
+ "notNull": true
777
+ },
778
+ "score": {
779
+ "name": "score",
780
+ "type": "integer",
781
+ "primaryKey": false,
782
+ "notNull": true
783
+ },
784
+ "created_at": {
785
+ "name": "created_at",
786
+ "type": "timestamp",
787
+ "primaryKey": false,
788
+ "notNull": true,
789
+ "default": "now()"
790
+ }
791
+ },
792
+ "indexes": {
793
+ "questionRating_questionId_idx": {
794
+ "name": "questionRating_questionId_idx",
795
+ "columns": [
796
+ {
797
+ "expression": "question_id",
798
+ "isExpression": false,
799
+ "asc": true,
800
+ "nulls": "last"
801
+ }
802
+ ],
803
+ "isUnique": false,
804
+ "concurrently": false,
805
+ "method": "btree",
806
+ "with": {}
807
+ }
808
+ },
809
+ "foreignKeys": {
810
+ "question_rating_user_id_user_id_fk": {
811
+ "name": "question_rating_user_id_user_id_fk",
812
+ "tableFrom": "question_rating",
813
+ "tableTo": "user",
814
+ "columnsFrom": [
815
+ "user_id"
816
+ ],
817
+ "columnsTo": [
818
+ "id"
819
+ ],
820
+ "onDelete": "cascade",
821
+ "onUpdate": "no action"
822
+ },
823
+ "question_rating_question_id_question_id_fk": {
824
+ "name": "question_rating_question_id_question_id_fk",
825
+ "tableFrom": "question_rating",
826
+ "tableTo": "question",
827
+ "columnsFrom": [
828
+ "question_id"
829
+ ],
830
+ "columnsTo": [
831
+ "id"
832
+ ],
833
+ "onDelete": "cascade",
834
+ "onUpdate": "no action"
835
+ }
836
+ },
837
+ "compositePrimaryKeys": {},
838
+ "uniqueConstraints": {
839
+ "questionRating_unique_idx": {
840
+ "name": "questionRating_unique_idx",
841
+ "nullsNotDistinct": false,
842
+ "columns": [
843
+ "user_id",
844
+ "question_id"
845
+ ]
846
+ }
847
+ },
848
+ "policies": {},
849
+ "checkConstraints": {},
850
+ "isRLSEnabled": false
851
+ },
852
+ "public.section_question": {
853
+ "name": "section_question",
854
+ "schema": "",
855
+ "columns": {
856
+ "id": {
857
+ "name": "id",
858
+ "type": "uuid",
859
+ "primaryKey": true,
860
+ "notNull": true,
861
+ "default": "gen_random_uuid()"
862
+ },
863
+ "section_id": {
864
+ "name": "section_id",
865
+ "type": "uuid",
866
+ "primaryKey": false,
867
+ "notNull": true
868
+ },
869
+ "question_id": {
870
+ "name": "question_id",
871
+ "type": "uuid",
872
+ "primaryKey": false,
873
+ "notNull": true
874
+ },
875
+ "order_index": {
876
+ "name": "order_index",
877
+ "type": "integer",
878
+ "primaryKey": false,
879
+ "notNull": true,
880
+ "default": 0
881
+ }
882
+ },
883
+ "indexes": {
884
+ "sectionQuestion_sectionId_idx": {
885
+ "name": "sectionQuestion_sectionId_idx",
886
+ "columns": [
887
+ {
888
+ "expression": "section_id",
889
+ "isExpression": false,
890
+ "asc": true,
891
+ "nulls": "last"
892
+ }
893
+ ],
894
+ "isUnique": false,
895
+ "concurrently": false,
896
+ "method": "btree",
897
+ "with": {}
898
+ }
899
+ },
900
+ "foreignKeys": {
901
+ "section_question_section_id_package_section_id_fk": {
902
+ "name": "section_question_section_id_package_section_id_fk",
903
+ "tableFrom": "section_question",
904
+ "tableTo": "package_section",
905
+ "columnsFrom": [
906
+ "section_id"
907
+ ],
908
+ "columnsTo": [
909
+ "id"
910
+ ],
911
+ "onDelete": "cascade",
912
+ "onUpdate": "no action"
913
+ },
914
+ "section_question_question_id_question_id_fk": {
915
+ "name": "section_question_question_id_question_id_fk",
916
+ "tableFrom": "section_question",
917
+ "tableTo": "question",
918
+ "columnsFrom": [
919
+ "question_id"
920
+ ],
921
+ "columnsTo": [
922
+ "id"
923
+ ],
924
+ "onDelete": "cascade",
925
+ "onUpdate": "no action"
926
+ }
927
+ },
928
+ "compositePrimaryKeys": {},
929
+ "uniqueConstraints": {
930
+ "sectionQuestion_unique_idx": {
931
+ "name": "sectionQuestion_unique_idx",
932
+ "nullsNotDistinct": false,
933
+ "columns": [
934
+ "section_id",
935
+ "question_id"
936
+ ]
937
+ }
938
+ },
939
+ "policies": {},
940
+ "checkConstraints": {},
941
+ "isRLSEnabled": false
942
+ },
943
+ "public.section_result": {
944
+ "name": "section_result",
945
+ "schema": "",
946
+ "columns": {
947
+ "id": {
948
+ "name": "id",
949
+ "type": "uuid",
950
+ "primaryKey": true,
951
+ "notNull": true,
952
+ "default": "gen_random_uuid()"
953
+ },
954
+ "attempt_id": {
955
+ "name": "attempt_id",
956
+ "type": "uuid",
957
+ "primaryKey": false,
958
+ "notNull": true
959
+ },
960
+ "section_type_id": {
961
+ "name": "section_type_id",
962
+ "type": "text",
963
+ "primaryKey": false,
964
+ "notNull": true
965
+ },
966
+ "score": {
967
+ "name": "score",
968
+ "type": "integer",
969
+ "primaryKey": false,
970
+ "notNull": false
971
+ },
972
+ "max_score": {
973
+ "name": "max_score",
974
+ "type": "integer",
975
+ "primaryKey": false,
976
+ "notNull": false
977
+ },
978
+ "time_spent_sec": {
979
+ "name": "time_spent_sec",
980
+ "type": "integer",
981
+ "primaryKey": false,
982
+ "notNull": false
983
+ },
984
+ "created_at": {
985
+ "name": "created_at",
986
+ "type": "timestamp",
987
+ "primaryKey": false,
988
+ "notNull": true,
989
+ "default": "now()"
990
+ }
991
+ },
992
+ "indexes": {
993
+ "sectionResult_attemptId_idx": {
994
+ "name": "sectionResult_attemptId_idx",
995
+ "columns": [
996
+ {
997
+ "expression": "attempt_id",
998
+ "isExpression": false,
999
+ "asc": true,
1000
+ "nulls": "last"
1001
+ }
1002
+ ],
1003
+ "isUnique": false,
1004
+ "concurrently": false,
1005
+ "method": "btree",
1006
+ "with": {}
1007
+ }
1008
+ },
1009
+ "foreignKeys": {
1010
+ "section_result_attempt_id_test_attempt_id_fk": {
1011
+ "name": "section_result_attempt_id_test_attempt_id_fk",
1012
+ "tableFrom": "section_result",
1013
+ "tableTo": "test_attempt",
1014
+ "columnsFrom": [
1015
+ "attempt_id"
1016
+ ],
1017
+ "columnsTo": [
1018
+ "id"
1019
+ ],
1020
+ "onDelete": "cascade",
1021
+ "onUpdate": "no action"
1022
+ },
1023
+ "section_result_section_type_id_section_type_id_fk": {
1024
+ "name": "section_result_section_type_id_section_type_id_fk",
1025
+ "tableFrom": "section_result",
1026
+ "tableTo": "section_type",
1027
+ "columnsFrom": [
1028
+ "section_type_id"
1029
+ ],
1030
+ "columnsTo": [
1031
+ "id"
1032
+ ],
1033
+ "onDelete": "cascade",
1034
+ "onUpdate": "no action"
1035
+ }
1036
+ },
1037
+ "compositePrimaryKeys": {},
1038
+ "uniqueConstraints": {},
1039
+ "policies": {},
1040
+ "checkConstraints": {},
1041
+ "isRLSEnabled": false
1042
+ },
1043
+ "public.section_type": {
1044
+ "name": "section_type",
1045
+ "schema": "",
1046
+ "columns": {
1047
+ "id": {
1048
+ "name": "id",
1049
+ "type": "text",
1050
+ "primaryKey": true,
1051
+ "notNull": true
1052
+ },
1053
+ "name": {
1054
+ "name": "name",
1055
+ "type": "text",
1056
+ "primaryKey": false,
1057
+ "notNull": true
1058
+ }
1059
+ },
1060
+ "indexes": {},
1061
+ "foreignKeys": {},
1062
+ "compositePrimaryKeys": {},
1063
+ "uniqueConstraints": {},
1064
+ "policies": {},
1065
+ "checkConstraints": {},
1066
+ "isRLSEnabled": false
1067
+ },
1068
+ "public.test_attempt": {
1069
+ "name": "test_attempt",
1070
+ "schema": "",
1071
+ "columns": {
1072
+ "id": {
1073
+ "name": "id",
1074
+ "type": "uuid",
1075
+ "primaryKey": true,
1076
+ "notNull": true,
1077
+ "default": "gen_random_uuid()"
1078
+ },
1079
+ "user_id": {
1080
+ "name": "user_id",
1081
+ "type": "text",
1082
+ "primaryKey": false,
1083
+ "notNull": true
1084
+ },
1085
+ "package_id": {
1086
+ "name": "package_id",
1087
+ "type": "uuid",
1088
+ "primaryKey": false,
1089
+ "notNull": false
1090
+ },
1091
+ "combo_id": {
1092
+ "name": "combo_id",
1093
+ "type": "uuid",
1094
+ "primaryKey": false,
1095
+ "notNull": false
1096
+ },
1097
+ "started_at": {
1098
+ "name": "started_at",
1099
+ "type": "timestamp",
1100
+ "primaryKey": false,
1101
+ "notNull": true,
1102
+ "default": "now()"
1103
+ },
1104
+ "finished_at": {
1105
+ "name": "finished_at",
1106
+ "type": "timestamp",
1107
+ "primaryKey": false,
1108
+ "notNull": false
1109
+ },
1110
+ "total_score": {
1111
+ "name": "total_score",
1112
+ "type": "integer",
1113
+ "primaryKey": false,
1114
+ "notNull": false
1115
+ },
1116
+ "max_score": {
1117
+ "name": "max_score",
1118
+ "type": "integer",
1119
+ "primaryKey": false,
1120
+ "notNull": false
1121
+ },
1122
+ "status": {
1123
+ "name": "status",
1124
+ "type": "text",
1125
+ "primaryKey": false,
1126
+ "notNull": true,
1127
+ "default": "'in_progress'"
1128
+ },
1129
+ "created_at": {
1130
+ "name": "created_at",
1131
+ "type": "timestamp",
1132
+ "primaryKey": false,
1133
+ "notNull": true,
1134
+ "default": "now()"
1135
+ }
1136
+ },
1137
+ "indexes": {
1138
+ "testAttempt_userId_idx": {
1139
+ "name": "testAttempt_userId_idx",
1140
+ "columns": [
1141
+ {
1142
+ "expression": "user_id",
1143
+ "isExpression": false,
1144
+ "asc": true,
1145
+ "nulls": "last"
1146
+ }
1147
+ ],
1148
+ "isUnique": false,
1149
+ "concurrently": false,
1150
+ "method": "btree",
1151
+ "with": {}
1152
+ },
1153
+ "testAttempt_packageId_idx": {
1154
+ "name": "testAttempt_packageId_idx",
1155
+ "columns": [
1156
+ {
1157
+ "expression": "package_id",
1158
+ "isExpression": false,
1159
+ "asc": true,
1160
+ "nulls": "last"
1161
+ }
1162
+ ],
1163
+ "isUnique": false,
1164
+ "concurrently": false,
1165
+ "method": "btree",
1166
+ "with": {}
1167
+ },
1168
+ "testAttempt_status_idx": {
1169
+ "name": "testAttempt_status_idx",
1170
+ "columns": [
1171
+ {
1172
+ "expression": "status",
1173
+ "isExpression": false,
1174
+ "asc": true,
1175
+ "nulls": "last"
1176
+ }
1177
+ ],
1178
+ "isUnique": false,
1179
+ "concurrently": false,
1180
+ "method": "btree",
1181
+ "with": {}
1182
+ }
1183
+ },
1184
+ "foreignKeys": {
1185
+ "test_attempt_user_id_user_id_fk": {
1186
+ "name": "test_attempt_user_id_user_id_fk",
1187
+ "tableFrom": "test_attempt",
1188
+ "tableTo": "user",
1189
+ "columnsFrom": [
1190
+ "user_id"
1191
+ ],
1192
+ "columnsTo": [
1193
+ "id"
1194
+ ],
1195
+ "onDelete": "cascade",
1196
+ "onUpdate": "no action"
1197
+ },
1198
+ "test_attempt_package_id_test_package_id_fk": {
1199
+ "name": "test_attempt_package_id_test_package_id_fk",
1200
+ "tableFrom": "test_attempt",
1201
+ "tableTo": "test_package",
1202
+ "columnsFrom": [
1203
+ "package_id"
1204
+ ],
1205
+ "columnsTo": [
1206
+ "id"
1207
+ ],
1208
+ "onDelete": "set null",
1209
+ "onUpdate": "no action"
1210
+ },
1211
+ "test_attempt_combo_id_combo_package_id_fk": {
1212
+ "name": "test_attempt_combo_id_combo_package_id_fk",
1213
+ "tableFrom": "test_attempt",
1214
+ "tableTo": "combo_package",
1215
+ "columnsFrom": [
1216
+ "combo_id"
1217
+ ],
1218
+ "columnsTo": [
1219
+ "id"
1220
+ ],
1221
+ "onDelete": "set null",
1222
+ "onUpdate": "no action"
1223
+ }
1224
+ },
1225
+ "compositePrimaryKeys": {},
1226
+ "uniqueConstraints": {},
1227
+ "policies": {},
1228
+ "checkConstraints": {},
1229
+ "isRLSEnabled": false
1230
+ },
1231
+ "public.test_package": {
1232
+ "name": "test_package",
1233
+ "schema": "",
1234
+ "columns": {
1235
+ "id": {
1236
+ "name": "id",
1237
+ "type": "uuid",
1238
+ "primaryKey": true,
1239
+ "notNull": true,
1240
+ "default": "gen_random_uuid()"
1241
+ },
1242
+ "title": {
1243
+ "name": "title",
1244
+ "type": "text",
1245
+ "primaryKey": false,
1246
+ "notNull": true
1247
+ },
1248
+ "description": {
1249
+ "name": "description",
1250
+ "type": "text",
1251
+ "primaryKey": false,
1252
+ "notNull": false
1253
+ },
1254
+ "exam_type_id": {
1255
+ "name": "exam_type_id",
1256
+ "type": "text",
1257
+ "primaryKey": false,
1258
+ "notNull": true
1259
+ },
1260
+ "creator_user_id": {
1261
+ "name": "creator_user_id",
1262
+ "type": "text",
1263
+ "primaryKey": false,
1264
+ "notNull": true
1265
+ },
1266
+ "is_public": {
1267
+ "name": "is_public",
1268
+ "type": "boolean",
1269
+ "primaryKey": false,
1270
+ "notNull": true,
1271
+ "default": false
1272
+ },
1273
+ "total_questions": {
1274
+ "name": "total_questions",
1275
+ "type": "integer",
1276
+ "primaryKey": false,
1277
+ "notNull": true,
1278
+ "default": 0
1279
+ },
1280
+ "total_sections": {
1281
+ "name": "total_sections",
1282
+ "type": "integer",
1283
+ "primaryKey": false,
1284
+ "notNull": true,
1285
+ "default": 0
1286
+ },
1287
+ "estimated_duration_min": {
1288
+ "name": "estimated_duration_min",
1289
+ "type": "integer",
1290
+ "primaryKey": false,
1291
+ "notNull": false
1292
+ },
1293
+ "usage_count": {
1294
+ "name": "usage_count",
1295
+ "type": "integer",
1296
+ "primaryKey": false,
1297
+ "notNull": true,
1298
+ "default": 0
1299
+ },
1300
+ "avg_rating": {
1301
+ "name": "avg_rating",
1302
+ "type": "integer",
1303
+ "primaryKey": false,
1304
+ "notNull": false
1305
+ },
1306
+ "created_at": {
1307
+ "name": "created_at",
1308
+ "type": "timestamp",
1309
+ "primaryKey": false,
1310
+ "notNull": true,
1311
+ "default": "now()"
1312
+ },
1313
+ "updated_at": {
1314
+ "name": "updated_at",
1315
+ "type": "timestamp",
1316
+ "primaryKey": false,
1317
+ "notNull": true,
1318
+ "default": "now()"
1319
+ }
1320
+ },
1321
+ "indexes": {
1322
+ "testPackage_examTypeId_idx": {
1323
+ "name": "testPackage_examTypeId_idx",
1324
+ "columns": [
1325
+ {
1326
+ "expression": "exam_type_id",
1327
+ "isExpression": false,
1328
+ "asc": true,
1329
+ "nulls": "last"
1330
+ }
1331
+ ],
1332
+ "isUnique": false,
1333
+ "concurrently": false,
1334
+ "method": "btree",
1335
+ "with": {}
1336
+ },
1337
+ "testPackage_creatorUserId_idx": {
1338
+ "name": "testPackage_creatorUserId_idx",
1339
+ "columns": [
1340
+ {
1341
+ "expression": "creator_user_id",
1342
+ "isExpression": false,
1343
+ "asc": true,
1344
+ "nulls": "last"
1345
+ }
1346
+ ],
1347
+ "isUnique": false,
1348
+ "concurrently": false,
1349
+ "method": "btree",
1350
+ "with": {}
1351
+ },
1352
+ "testPackage_isPublic_idx": {
1353
+ "name": "testPackage_isPublic_idx",
1354
+ "columns": [
1355
+ {
1356
+ "expression": "is_public",
1357
+ "isExpression": false,
1358
+ "asc": true,
1359
+ "nulls": "last"
1360
+ }
1361
+ ],
1362
+ "isUnique": false,
1363
+ "concurrently": false,
1364
+ "method": "btree",
1365
+ "with": {}
1366
+ }
1367
+ },
1368
+ "foreignKeys": {
1369
+ "test_package_exam_type_id_exam_type_id_fk": {
1370
+ "name": "test_package_exam_type_id_exam_type_id_fk",
1371
+ "tableFrom": "test_package",
1372
+ "tableTo": "exam_type",
1373
+ "columnsFrom": [
1374
+ "exam_type_id"
1375
+ ],
1376
+ "columnsTo": [
1377
+ "id"
1378
+ ],
1379
+ "onDelete": "cascade",
1380
+ "onUpdate": "no action"
1381
+ },
1382
+ "test_package_creator_user_id_user_id_fk": {
1383
+ "name": "test_package_creator_user_id_user_id_fk",
1384
+ "tableFrom": "test_package",
1385
+ "tableTo": "user",
1386
+ "columnsFrom": [
1387
+ "creator_user_id"
1388
+ ],
1389
+ "columnsTo": [
1390
+ "id"
1391
+ ],
1392
+ "onDelete": "cascade",
1393
+ "onUpdate": "no action"
1394
+ }
1395
+ },
1396
+ "compositePrimaryKeys": {},
1397
+ "uniqueConstraints": {},
1398
+ "policies": {},
1399
+ "checkConstraints": {},
1400
+ "isRLSEnabled": false
1401
+ },
1402
+ "public.user_api_key": {
1403
+ "name": "user_api_key",
1404
+ "schema": "",
1405
+ "columns": {
1406
+ "id": {
1407
+ "name": "id",
1408
+ "type": "uuid",
1409
+ "primaryKey": true,
1410
+ "notNull": true,
1411
+ "default": "gen_random_uuid()"
1412
+ },
1413
+ "user_id": {
1414
+ "name": "user_id",
1415
+ "type": "text",
1416
+ "primaryKey": false,
1417
+ "notNull": true
1418
+ },
1419
+ "provider": {
1420
+ "name": "provider",
1421
+ "type": "text",
1422
+ "primaryKey": false,
1423
+ "notNull": true,
1424
+ "default": "'openai'"
1425
+ },
1426
+ "base_url": {
1427
+ "name": "base_url",
1428
+ "type": "text",
1429
+ "primaryKey": false,
1430
+ "notNull": false
1431
+ },
1432
+ "api_key_encrypted": {
1433
+ "name": "api_key_encrypted",
1434
+ "type": "text",
1435
+ "primaryKey": false,
1436
+ "notNull": true
1437
+ },
1438
+ "model_name": {
1439
+ "name": "model_name",
1440
+ "type": "text",
1441
+ "primaryKey": false,
1442
+ "notNull": true,
1443
+ "default": "'gpt-4o-mini'"
1444
+ },
1445
+ "is_active": {
1446
+ "name": "is_active",
1447
+ "type": "boolean",
1448
+ "primaryKey": false,
1449
+ "notNull": true,
1450
+ "default": true
1451
+ },
1452
+ "created_at": {
1453
+ "name": "created_at",
1454
+ "type": "timestamp",
1455
+ "primaryKey": false,
1456
+ "notNull": true,
1457
+ "default": "now()"
1458
+ },
1459
+ "updated_at": {
1460
+ "name": "updated_at",
1461
+ "type": "timestamp",
1462
+ "primaryKey": false,
1463
+ "notNull": true,
1464
+ "default": "now()"
1465
+ }
1466
+ },
1467
+ "indexes": {
1468
+ "userApiKey_userId_idx": {
1469
+ "name": "userApiKey_userId_idx",
1470
+ "columns": [
1471
+ {
1472
+ "expression": "user_id",
1473
+ "isExpression": false,
1474
+ "asc": true,
1475
+ "nulls": "last"
1476
+ }
1477
+ ],
1478
+ "isUnique": false,
1479
+ "concurrently": false,
1480
+ "method": "btree",
1481
+ "with": {}
1482
+ },
1483
+ "userApiKey_isActive_idx": {
1484
+ "name": "userApiKey_isActive_idx",
1485
+ "columns": [
1486
+ {
1487
+ "expression": "is_active",
1488
+ "isExpression": false,
1489
+ "asc": true,
1490
+ "nulls": "last"
1491
+ }
1492
+ ],
1493
+ "isUnique": false,
1494
+ "concurrently": false,
1495
+ "method": "btree",
1496
+ "with": {}
1497
+ }
1498
+ },
1499
+ "foreignKeys": {
1500
+ "user_api_key_user_id_user_id_fk": {
1501
+ "name": "user_api_key_user_id_user_id_fk",
1502
+ "tableFrom": "user_api_key",
1503
+ "tableTo": "user",
1504
+ "columnsFrom": [
1505
+ "user_id"
1506
+ ],
1507
+ "columnsTo": [
1508
+ "id"
1509
+ ],
1510
+ "onDelete": "cascade",
1511
+ "onUpdate": "no action"
1512
+ }
1513
+ },
1514
+ "compositePrimaryKeys": {},
1515
+ "uniqueConstraints": {},
1516
+ "policies": {},
1517
+ "checkConstraints": {},
1518
+ "isRLSEnabled": false
1519
+ },
1520
+ "public.account": {
1521
+ "name": "account",
1522
+ "schema": "",
1523
+ "columns": {
1524
+ "id": {
1525
+ "name": "id",
1526
+ "type": "text",
1527
+ "primaryKey": true,
1528
+ "notNull": true
1529
+ },
1530
+ "account_id": {
1531
+ "name": "account_id",
1532
+ "type": "text",
1533
+ "primaryKey": false,
1534
+ "notNull": true
1535
+ },
1536
+ "provider_id": {
1537
+ "name": "provider_id",
1538
+ "type": "text",
1539
+ "primaryKey": false,
1540
+ "notNull": true
1541
+ },
1542
+ "user_id": {
1543
+ "name": "user_id",
1544
+ "type": "text",
1545
+ "primaryKey": false,
1546
+ "notNull": true
1547
+ },
1548
+ "access_token": {
1549
+ "name": "access_token",
1550
+ "type": "text",
1551
+ "primaryKey": false,
1552
+ "notNull": false
1553
+ },
1554
+ "refresh_token": {
1555
+ "name": "refresh_token",
1556
+ "type": "text",
1557
+ "primaryKey": false,
1558
+ "notNull": false
1559
+ },
1560
+ "id_token": {
1561
+ "name": "id_token",
1562
+ "type": "text",
1563
+ "primaryKey": false,
1564
+ "notNull": false
1565
+ },
1566
+ "access_token_expires_at": {
1567
+ "name": "access_token_expires_at",
1568
+ "type": "timestamp",
1569
+ "primaryKey": false,
1570
+ "notNull": false
1571
+ },
1572
+ "refresh_token_expires_at": {
1573
+ "name": "refresh_token_expires_at",
1574
+ "type": "timestamp",
1575
+ "primaryKey": false,
1576
+ "notNull": false
1577
+ },
1578
+ "scope": {
1579
+ "name": "scope",
1580
+ "type": "text",
1581
+ "primaryKey": false,
1582
+ "notNull": false
1583
+ },
1584
+ "password": {
1585
+ "name": "password",
1586
+ "type": "text",
1587
+ "primaryKey": false,
1588
+ "notNull": false
1589
+ },
1590
+ "created_at": {
1591
+ "name": "created_at",
1592
+ "type": "timestamp",
1593
+ "primaryKey": false,
1594
+ "notNull": true,
1595
+ "default": "now()"
1596
+ },
1597
+ "updated_at": {
1598
+ "name": "updated_at",
1599
+ "type": "timestamp",
1600
+ "primaryKey": false,
1601
+ "notNull": true
1602
+ }
1603
+ },
1604
+ "indexes": {
1605
+ "account_userId_idx": {
1606
+ "name": "account_userId_idx",
1607
+ "columns": [
1608
+ {
1609
+ "expression": "user_id",
1610
+ "isExpression": false,
1611
+ "asc": true,
1612
+ "nulls": "last"
1613
+ }
1614
+ ],
1615
+ "isUnique": false,
1616
+ "concurrently": false,
1617
+ "method": "btree",
1618
+ "with": {}
1619
+ }
1620
+ },
1621
+ "foreignKeys": {
1622
+ "account_user_id_user_id_fk": {
1623
+ "name": "account_user_id_user_id_fk",
1624
+ "tableFrom": "account",
1625
+ "tableTo": "user",
1626
+ "columnsFrom": [
1627
+ "user_id"
1628
+ ],
1629
+ "columnsTo": [
1630
+ "id"
1631
+ ],
1632
+ "onDelete": "cascade",
1633
+ "onUpdate": "no action"
1634
+ }
1635
+ },
1636
+ "compositePrimaryKeys": {},
1637
+ "uniqueConstraints": {},
1638
+ "policies": {},
1639
+ "checkConstraints": {},
1640
+ "isRLSEnabled": false
1641
+ },
1642
+ "public.session": {
1643
+ "name": "session",
1644
+ "schema": "",
1645
+ "columns": {
1646
+ "id": {
1647
+ "name": "id",
1648
+ "type": "text",
1649
+ "primaryKey": true,
1650
+ "notNull": true
1651
+ },
1652
+ "expires_at": {
1653
+ "name": "expires_at",
1654
+ "type": "timestamp",
1655
+ "primaryKey": false,
1656
+ "notNull": true
1657
+ },
1658
+ "token": {
1659
+ "name": "token",
1660
+ "type": "text",
1661
+ "primaryKey": false,
1662
+ "notNull": true
1663
+ },
1664
+ "created_at": {
1665
+ "name": "created_at",
1666
+ "type": "timestamp",
1667
+ "primaryKey": false,
1668
+ "notNull": true,
1669
+ "default": "now()"
1670
+ },
1671
+ "updated_at": {
1672
+ "name": "updated_at",
1673
+ "type": "timestamp",
1674
+ "primaryKey": false,
1675
+ "notNull": true
1676
+ },
1677
+ "ip_address": {
1678
+ "name": "ip_address",
1679
+ "type": "text",
1680
+ "primaryKey": false,
1681
+ "notNull": false
1682
+ },
1683
+ "user_agent": {
1684
+ "name": "user_agent",
1685
+ "type": "text",
1686
+ "primaryKey": false,
1687
+ "notNull": false
1688
+ },
1689
+ "user_id": {
1690
+ "name": "user_id",
1691
+ "type": "text",
1692
+ "primaryKey": false,
1693
+ "notNull": true
1694
+ }
1695
+ },
1696
+ "indexes": {
1697
+ "session_userId_idx": {
1698
+ "name": "session_userId_idx",
1699
+ "columns": [
1700
+ {
1701
+ "expression": "user_id",
1702
+ "isExpression": false,
1703
+ "asc": true,
1704
+ "nulls": "last"
1705
+ }
1706
+ ],
1707
+ "isUnique": false,
1708
+ "concurrently": false,
1709
+ "method": "btree",
1710
+ "with": {}
1711
+ }
1712
+ },
1713
+ "foreignKeys": {
1714
+ "session_user_id_user_id_fk": {
1715
+ "name": "session_user_id_user_id_fk",
1716
+ "tableFrom": "session",
1717
+ "tableTo": "user",
1718
+ "columnsFrom": [
1719
+ "user_id"
1720
+ ],
1721
+ "columnsTo": [
1722
+ "id"
1723
+ ],
1724
+ "onDelete": "cascade",
1725
+ "onUpdate": "no action"
1726
+ }
1727
+ },
1728
+ "compositePrimaryKeys": {},
1729
+ "uniqueConstraints": {
1730
+ "session_token_unique": {
1731
+ "name": "session_token_unique",
1732
+ "nullsNotDistinct": false,
1733
+ "columns": [
1734
+ "token"
1735
+ ]
1736
+ }
1737
+ },
1738
+ "policies": {},
1739
+ "checkConstraints": {},
1740
+ "isRLSEnabled": false
1741
+ },
1742
+ "public.user": {
1743
+ "name": "user",
1744
+ "schema": "",
1745
+ "columns": {
1746
+ "id": {
1747
+ "name": "id",
1748
+ "type": "text",
1749
+ "primaryKey": true,
1750
+ "notNull": true
1751
+ },
1752
+ "name": {
1753
+ "name": "name",
1754
+ "type": "text",
1755
+ "primaryKey": false,
1756
+ "notNull": true
1757
+ },
1758
+ "email": {
1759
+ "name": "email",
1760
+ "type": "text",
1761
+ "primaryKey": false,
1762
+ "notNull": true
1763
+ },
1764
+ "email_verified": {
1765
+ "name": "email_verified",
1766
+ "type": "boolean",
1767
+ "primaryKey": false,
1768
+ "notNull": true,
1769
+ "default": false
1770
+ },
1771
+ "image": {
1772
+ "name": "image",
1773
+ "type": "text",
1774
+ "primaryKey": false,
1775
+ "notNull": false
1776
+ },
1777
+ "created_at": {
1778
+ "name": "created_at",
1779
+ "type": "timestamp",
1780
+ "primaryKey": false,
1781
+ "notNull": true,
1782
+ "default": "now()"
1783
+ },
1784
+ "updated_at": {
1785
+ "name": "updated_at",
1786
+ "type": "timestamp",
1787
+ "primaryKey": false,
1788
+ "notNull": true,
1789
+ "default": "now()"
1790
+ }
1791
+ },
1792
+ "indexes": {},
1793
+ "foreignKeys": {},
1794
+ "compositePrimaryKeys": {},
1795
+ "uniqueConstraints": {
1796
+ "user_email_unique": {
1797
+ "name": "user_email_unique",
1798
+ "nullsNotDistinct": false,
1799
+ "columns": [
1800
+ "email"
1801
+ ]
1802
+ }
1803
+ },
1804
+ "policies": {},
1805
+ "checkConstraints": {},
1806
+ "isRLSEnabled": false
1807
+ },
1808
+ "public.verification": {
1809
+ "name": "verification",
1810
+ "schema": "",
1811
+ "columns": {
1812
+ "id": {
1813
+ "name": "id",
1814
+ "type": "text",
1815
+ "primaryKey": true,
1816
+ "notNull": true
1817
+ },
1818
+ "identifier": {
1819
+ "name": "identifier",
1820
+ "type": "text",
1821
+ "primaryKey": false,
1822
+ "notNull": true
1823
+ },
1824
+ "value": {
1825
+ "name": "value",
1826
+ "type": "text",
1827
+ "primaryKey": false,
1828
+ "notNull": true
1829
+ },
1830
+ "expires_at": {
1831
+ "name": "expires_at",
1832
+ "type": "timestamp",
1833
+ "primaryKey": false,
1834
+ "notNull": true
1835
+ },
1836
+ "created_at": {
1837
+ "name": "created_at",
1838
+ "type": "timestamp",
1839
+ "primaryKey": false,
1840
+ "notNull": true,
1841
+ "default": "now()"
1842
+ },
1843
+ "updated_at": {
1844
+ "name": "updated_at",
1845
+ "type": "timestamp",
1846
+ "primaryKey": false,
1847
+ "notNull": true,
1848
+ "default": "now()"
1849
+ }
1850
+ },
1851
+ "indexes": {
1852
+ "verification_identifier_idx": {
1853
+ "name": "verification_identifier_idx",
1854
+ "columns": [
1855
+ {
1856
+ "expression": "identifier",
1857
+ "isExpression": false,
1858
+ "asc": true,
1859
+ "nulls": "last"
1860
+ }
1861
+ ],
1862
+ "isUnique": false,
1863
+ "concurrently": false,
1864
+ "method": "btree",
1865
+ "with": {}
1866
+ }
1867
+ },
1868
+ "foreignKeys": {},
1869
+ "compositePrimaryKeys": {},
1870
+ "uniqueConstraints": {},
1871
+ "policies": {},
1872
+ "checkConstraints": {},
1873
+ "isRLSEnabled": false
1874
+ }
1875
+ },
1876
+ "enums": {},
1877
+ "schemas": {},
1878
+ "sequences": {},
1879
+ "roles": {},
1880
+ "policies": {},
1881
+ "views": {},
1882
+ "_meta": {
1883
+ "columns": {},
1884
+ "schemas": {},
1885
+ "tables": {}
1886
+ }
1887
+ }
packages/db/src/migrations/meta/_journal.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1777096566249,
9
+ "tag": "0000_amazing_gamora",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
packages/db/src/schema/app.ts ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { relations } from "drizzle-orm";
2
+ import {
3
+ pgTable,
4
+ text,
5
+ timestamp,
6
+ boolean,
7
+ integer,
8
+ index,
9
+ jsonb,
10
+ uuid,
11
+ unique,
12
+ } from "drizzle-orm/pg-core";
13
+
14
+ // ── Reference / Lookup Tables ──────────────────────────────
15
+
16
+ export const examType = pgTable("exam_type", {
17
+ id: text("id").primaryKey(), // e.g. "IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"
18
+ name: text("name").notNull(),
19
+ language: text("language").notNull(), // e.g. "English", "Japanese"
20
+ description: text("description"),
21
+ });
22
+
23
+ export const sectionType = pgTable("section_type", {
24
+ id: text("id").primaryKey(), // e.g. "READING", "WRITING", "LISTENING", "SPEAKING"
25
+ name: text("name").notNull(),
26
+ });
27
+
28
+ // ── Questions ──────────────────────────────────────────────
29
+
30
+ export const question = pgTable(
31
+ "question",
32
+ {
33
+ id: uuid("id").defaultRandom().primaryKey(),
34
+ examTypeId: text("exam_type_id")
35
+ .notNull()
36
+ .references(() => examType.id, { onDelete: "cascade" }),
37
+ sectionTypeId: text("section_type_id")
38
+ .notNull()
39
+ .references(() => sectionType.id, { onDelete: "cascade" }),
40
+ // question format, e.g. "multiple_choice", "true_false_not_given", "matching", "fill_blank", "synonym", "cloze", "reference", "author_view", "sentence_completion", "summary_completion", "kanji_reading", "particle_choice", "article_case"
41
+ format: text("format").notNull(),
42
+ // the reading passage / context text
43
+ passageText: text("passage_text").notNull(),
44
+ // the actual question prompt
45
+ questionText: text("question_text").notNull(),
46
+ // array of options for multiple choice / matching
47
+ options: jsonb("options"),
48
+ // correct answer β€” could be an option key, text, or array for multiple correct
49
+ correctAnswer: text("correct_answer").notNull(),
50
+ // explanation shown after submission
51
+ explanation: text("explanation"),
52
+ // difficulty 1-5
53
+ difficulty: integer("difficulty").notNull().default(3),
54
+ // e.g. ["grammar", "vocabulary", "inference", "main_idea", "detail"]
55
+ skillTags: text("skill_tags").array().default([]),
56
+ source: text("source").notNull().default("manual"), // "ai" | "manual"
57
+ aiModel: text("ai_model"),
58
+ aiPromptUsed: text("ai_prompt_used"),
59
+ creatorUserId: text("creator_user_id")
60
+ .notNull()
61
+ .references(() => user.id, { onDelete: "cascade" }),
62
+ isPublic: boolean("is_public").default(false).notNull(),
63
+ usageCount: integer("usage_count").default(0).notNull(),
64
+ avgRating: integer("avg_rating"),
65
+ createdAt: timestamp("created_at").defaultNow().notNull(),
66
+ updatedAt: timestamp("updated_at")
67
+ .defaultNow()
68
+ .$onUpdate(() => new Date())
69
+ .notNull(),
70
+ },
71
+ (table) => [
72
+ index("question_examTypeId_idx").on(table.examTypeId),
73
+ index("question_sectionTypeId_idx").on(table.sectionTypeId),
74
+ index("question_creatorUserId_idx").on(table.creatorUserId),
75
+ index("question_isPublic_idx").on(table.isPublic),
76
+ index("question_format_idx").on(table.format),
77
+ index("question_difficulty_idx").on(table.difficulty),
78
+ ],
79
+ );
80
+
81
+ // ── Packages (Test Bundles) ────────────────────────────────
82
+
83
+ export const testPackage = pgTable(
84
+ "test_package",
85
+ {
86
+ id: uuid("id").defaultRandom().primaryKey(),
87
+ title: text("title").notNull(),
88
+ description: text("description"),
89
+ examTypeId: text("exam_type_id")
90
+ .notNull()
91
+ .references(() => examType.id, { onDelete: "cascade" }),
92
+ creatorUserId: text("creator_user_id")
93
+ .notNull()
94
+ .references(() => user.id, { onDelete: "cascade" }),
95
+ isPublic: boolean("is_public").default(false).notNull(),
96
+ totalQuestions: integer("total_questions").default(0).notNull(),
97
+ totalSections: integer("total_sections").default(0).notNull(),
98
+ estimatedDurationMin: integer("estimated_duration_min"),
99
+ usageCount: integer("usage_count").default(0).notNull(),
100
+ avgRating: integer("avg_rating"),
101
+ createdAt: timestamp("created_at").defaultNow().notNull(),
102
+ updatedAt: timestamp("updated_at")
103
+ .defaultNow()
104
+ .$onUpdate(() => new Date())
105
+ .notNull(),
106
+ },
107
+ (table) => [
108
+ index("testPackage_examTypeId_idx").on(table.examTypeId),
109
+ index("testPackage_creatorUserId_idx").on(table.creatorUserId),
110
+ index("testPackage_isPublic_idx").on(table.isPublic),
111
+ ],
112
+ );
113
+
114
+ export const packageSection = pgTable(
115
+ "package_section",
116
+ {
117
+ id: uuid("id").defaultRandom().primaryKey(),
118
+ packageId: uuid("package_id")
119
+ .notNull()
120
+ .references(() => testPackage.id, { onDelete: "cascade" }),
121
+ sectionTypeId: text("section_type_id")
122
+ .notNull()
123
+ .references(() => sectionType.id, { onDelete: "cascade" }),
124
+ title: text("title").notNull(),
125
+ orderIndex: integer("order_index").notNull().default(0),
126
+ createdAt: timestamp("created_at").defaultNow().notNull(),
127
+ },
128
+ (table) => [
129
+ index("packageSection_packageId_idx").on(table.packageId),
130
+ index("packageSection_sectionTypeId_idx").on(table.sectionTypeId),
131
+ ],
132
+ );
133
+
134
+ export const sectionQuestion = pgTable(
135
+ "section_question",
136
+ {
137
+ id: uuid("id").defaultRandom().primaryKey(),
138
+ sectionId: uuid("section_id")
139
+ .notNull()
140
+ .references(() => packageSection.id, { onDelete: "cascade" }),
141
+ questionId: uuid("question_id")
142
+ .notNull()
143
+ .references(() => question.id, { onDelete: "cascade" }),
144
+ orderIndex: integer("order_index").notNull().default(0),
145
+ },
146
+ (table) => [
147
+ unique("sectionQuestion_unique_idx").on(table.sectionId, table.questionId),
148
+ index("sectionQuestion_sectionId_idx").on(table.sectionId),
149
+ ],
150
+ );
151
+
152
+ // ── Combo Packages ─────────────────────────────────────────
153
+
154
+ export const comboPackage = pgTable(
155
+ "combo_package",
156
+ {
157
+ id: uuid("id").defaultRandom().primaryKey(),
158
+ title: text("title").notNull(),
159
+ description: text("description"),
160
+ creatorUserId: text("creator_user_id")
161
+ .notNull()
162
+ .references(() => user.id, { onDelete: "cascade" }),
163
+ isPublic: boolean("is_public").default(false).notNull(),
164
+ createdAt: timestamp("created_at").defaultNow().notNull(),
165
+ updatedAt: timestamp("updated_at")
166
+ .defaultNow()
167
+ .$onUpdate(() => new Date())
168
+ .notNull(),
169
+ },
170
+ (table) => [
171
+ index("comboPackage_creatorUserId_idx").on(table.creatorUserId),
172
+ index("comboPackage_isPublic_idx").on(table.isPublic),
173
+ ],
174
+ );
175
+
176
+ export const comboSection = pgTable(
177
+ "combo_section",
178
+ {
179
+ id: uuid("id").defaultRandom().primaryKey(),
180
+ comboId: uuid("combo_id")
181
+ .notNull()
182
+ .references(() => comboPackage.id, { onDelete: "cascade" }),
183
+ sourcePackageId: uuid("source_package_id")
184
+ .notNull()
185
+ .references(() => testPackage.id, { onDelete: "cascade" }),
186
+ sourceSectionId: uuid("source_section_id")
187
+ .notNull()
188
+ .references(() => packageSection.id, { onDelete: "cascade" }),
189
+ orderIndex: integer("order_index").notNull().default(0),
190
+ },
191
+ (table) => [
192
+ index("comboSection_comboId_idx").on(table.comboId),
193
+ index("comboSection_sourcePackageId_idx").on(table.sourcePackageId),
194
+ ],
195
+ );
196
+
197
+ // ── Test Attempts & Results ────────────────────────────────
198
+
199
+ export const testAttempt = pgTable(
200
+ "test_attempt",
201
+ {
202
+ id: uuid("id").defaultRandom().primaryKey(),
203
+ userId: text("user_id")
204
+ .notNull()
205
+ .references(() => user.id, { onDelete: "cascade" }),
206
+ // could be either a package or a combo
207
+ packageId: uuid("package_id").references(() => testPackage.id, { onDelete: "set null" }),
208
+ comboId: uuid("combo_id").references(() => comboPackage.id, { onDelete: "set null" }),
209
+ startedAt: timestamp("started_at").defaultNow().notNull(),
210
+ finishedAt: timestamp("finished_at"),
211
+ totalScore: integer("total_score"),
212
+ maxScore: integer("max_score"),
213
+ status: text("status").notNull().default("in_progress"), // "in_progress" | "completed" | "abandoned"
214
+ createdAt: timestamp("created_at").defaultNow().notNull(),
215
+ },
216
+ (table) => [
217
+ index("testAttempt_userId_idx").on(table.userId),
218
+ index("testAttempt_packageId_idx").on(table.packageId),
219
+ index("testAttempt_status_idx").on(table.status),
220
+ ],
221
+ );
222
+
223
+ export const sectionResult = pgTable(
224
+ "section_result",
225
+ {
226
+ id: uuid("id").defaultRandom().primaryKey(),
227
+ attemptId: uuid("attempt_id")
228
+ .notNull()
229
+ .references(() => testAttempt.id, { onDelete: "cascade" }),
230
+ sectionTypeId: text("section_type_id")
231
+ .notNull()
232
+ .references(() => sectionType.id, { onDelete: "cascade" }),
233
+ score: integer("score"),
234
+ maxScore: integer("max_score"),
235
+ timeSpentSec: integer("time_spent_sec"),
236
+ createdAt: timestamp("created_at").defaultNow().notNull(),
237
+ },
238
+ (table) => [
239
+ index("sectionResult_attemptId_idx").on(table.attemptId),
240
+ ],
241
+ );
242
+
243
+ export const answer = pgTable(
244
+ "answer",
245
+ {
246
+ id: uuid("id").defaultRandom().primaryKey(),
247
+ sectionResultId: uuid("section_result_id")
248
+ .notNull()
249
+ .references(() => sectionResult.id, { onDelete: "cascade" }),
250
+ questionId: uuid("question_id")
251
+ .notNull()
252
+ .references(() => question.id, { onDelete: "cascade" }),
253
+ userAnswer: text("user_answer"),
254
+ isCorrect: boolean("is_correct"),
255
+ timeSpentSec: integer("time_spent_sec"),
256
+ createdAt: timestamp("created_at").defaultNow().notNull(),
257
+ },
258
+ (table) => [
259
+ unique("answer_unique_idx").on(table.sectionResultId, table.questionId),
260
+ index("answer_questionId_idx").on(table.questionId),
261
+ ],
262
+ );
263
+
264
+ // ── Question Ratings ───────────────────────────────────────
265
+
266
+ export const questionRating = pgTable(
267
+ "question_rating",
268
+ {
269
+ id: uuid("id").defaultRandom().primaryKey(),
270
+ userId: text("user_id")
271
+ .notNull()
272
+ .references(() => user.id, { onDelete: "cascade" }),
273
+ questionId: uuid("question_id")
274
+ .notNull()
275
+ .references(() => question.id, { onDelete: "cascade" }),
276
+ score: integer("score").notNull(), // 1-5
277
+ createdAt: timestamp("created_at").defaultNow().notNull(),
278
+ },
279
+ (table) => [
280
+ unique("questionRating_unique_idx").on(table.userId, table.questionId),
281
+ index("questionRating_questionId_idx").on(table.questionId),
282
+ ],
283
+ );
284
+
285
+ // ── Relations ──────────────────────────────────────────────
286
+
287
+ // Need user from auth.ts for relations
288
+ import { user } from "./auth";
289
+
290
+ export const questionRelations = relations(question, ({ one, many }) => ({
291
+ examType: one(examType, {
292
+ fields: [question.examTypeId],
293
+ references: [examType.id],
294
+ }),
295
+ sectionType: one(sectionType, {
296
+ fields: [question.sectionTypeId],
297
+ references: [sectionType.id],
298
+ }),
299
+ creator: one(user, {
300
+ fields: [question.creatorUserId],
301
+ references: [user.id],
302
+ }),
303
+ sectionQuestions: many(sectionQuestion),
304
+ ratings: many(questionRating),
305
+ answers: many(answer),
306
+ }));
307
+
308
+ export const examTypeRelations = relations(examType, ({ many }) => ({
309
+ questions: many(question),
310
+ packages: many(testPackage),
311
+ }));
312
+
313
+ export const sectionTypeRelations = relations(sectionType, ({ many }) => ({
314
+ questions: many(question),
315
+ packageSections: many(packageSection),
316
+ sectionResults: many(sectionResult),
317
+ }));
318
+
319
+ export const testPackageRelations = relations(testPackage, ({ one, many }) => ({
320
+ examType: one(examType, {
321
+ fields: [testPackage.examTypeId],
322
+ references: [examType.id],
323
+ }),
324
+ creator: one(user, {
325
+ fields: [testPackage.creatorUserId],
326
+ references: [user.id],
327
+ }),
328
+ sections: many(packageSection),
329
+ comboSections: many(comboSection),
330
+ attempts: many(testAttempt),
331
+ }));
332
+
333
+ export const packageSectionRelations = relations(packageSection, ({ one, many }) => ({
334
+ package: one(testPackage, {
335
+ fields: [packageSection.packageId],
336
+ references: [testPackage.id],
337
+ }),
338
+ sectionType: one(sectionType, {
339
+ fields: [packageSection.sectionTypeId],
340
+ references: [sectionType.id],
341
+ }),
342
+ questions: many(sectionQuestion),
343
+ comboSections: many(comboSection),
344
+ }));
345
+
346
+ export const sectionQuestionRelations = relations(sectionQuestion, ({ one }) => ({
347
+ section: one(packageSection, {
348
+ fields: [sectionQuestion.sectionId],
349
+ references: [packageSection.id],
350
+ }),
351
+ question: one(question, {
352
+ fields: [sectionQuestion.questionId],
353
+ references: [question.id],
354
+ }),
355
+ }));
356
+
357
+ export const comboPackageRelations = relations(comboPackage, ({ one, many }) => ({
358
+ creator: one(user, {
359
+ fields: [comboPackage.creatorUserId],
360
+ references: [user.id],
361
+ }),
362
+ sections: many(comboSection),
363
+ attempts: many(testAttempt),
364
+ }));
365
+
366
+ export const comboSectionRelations = relations(comboSection, ({ one }) => ({
367
+ combo: one(comboPackage, {
368
+ fields: [comboSection.comboId],
369
+ references: [comboPackage.id],
370
+ }),
371
+ sourcePackage: one(testPackage, {
372
+ fields: [comboSection.sourcePackageId],
373
+ references: [testPackage.id],
374
+ }),
375
+ sourceSection: one(packageSection, {
376
+ fields: [comboSection.sourceSectionId],
377
+ references: [packageSection.id],
378
+ }),
379
+ }));
380
+
381
+ export const testAttemptRelations = relations(testAttempt, ({ one, many }) => ({
382
+ user: one(user, {
383
+ fields: [testAttempt.userId],
384
+ references: [user.id],
385
+ }),
386
+ package: one(testPackage, {
387
+ fields: [testAttempt.packageId],
388
+ references: [testPackage.id],
389
+ }),
390
+ combo: one(comboPackage, {
391
+ fields: [testAttempt.comboId],
392
+ references: [comboPackage.id],
393
+ }),
394
+ sectionResults: many(sectionResult),
395
+ }));
396
+
397
+ export const sectionResultRelations = relations(sectionResult, ({ one, many }) => ({
398
+ attempt: one(testAttempt, {
399
+ fields: [sectionResult.attemptId],
400
+ references: [testAttempt.id],
401
+ }),
402
+ sectionType: one(sectionType, {
403
+ fields: [sectionResult.sectionTypeId],
404
+ references: [sectionType.id],
405
+ }),
406
+ answers: many(answer),
407
+ }));
408
+
409
+ export const answerRelations = relations(answer, ({ one }) => ({
410
+ sectionResult: one(sectionResult, {
411
+ fields: [answer.sectionResultId],
412
+ references: [sectionResult.id],
413
+ }),
414
+ question: one(question, {
415
+ fields: [answer.questionId],
416
+ references: [question.id],
417
+ }),
418
+ }));
419
+
420
+ export const questionRatingRelations = relations(questionRating, ({ one }) => ({
421
+ user: one(user, {
422
+ fields: [questionRating.userId],
423
+ references: [user.id],
424
+ }),
425
+ question: one(question, {
426
+ fields: [questionRating.questionId],
427
+ references: [question.id],
428
+ }),
429
+ }));
packages/db/src/schema/index.ts CHANGED
@@ -1,2 +1,2 @@
1
  export * from "./auth";
2
- export {};
 
1
  export * from "./auth";
2
+ export * from "./app";
packages/ui/src/styles/globals.css CHANGED
@@ -1,120 +1,287 @@
 
1
  @import "tailwindcss";
2
  @import "tw-animate-css";
3
  @import "shadcn/tailwind.css";
 
4
  @source "../../../apps/**/*.{ts,tsx}";
5
  @source "../**/*.{ts,tsx}";
6
 
7
  @custom-variant dark (&:is(.dark *));
8
 
 
 
9
  :root {
10
- --background: oklch(1 0 0);
11
- --foreground: oklch(0.145 0 0);
12
- --card: oklch(1 0 0);
13
- --card-foreground: oklch(0.145 0 0);
14
- --popover: oklch(1 0 0);
15
- --popover-foreground: oklch(0.145 0 0);
16
- --primary: oklch(0.205 0 0);
17
- --primary-foreground: oklch(0.985 0 0);
18
- --secondary: oklch(0.97 0 0);
19
- --secondary-foreground: oklch(0.205 0 0);
20
- --muted: oklch(0.97 0 0);
21
- --muted-foreground: oklch(0.556 0 0);
22
- --accent: oklch(0.97 0 0);
23
- --accent-foreground: oklch(0.205 0 0);
24
- --destructive: oklch(0.58 0.22 27);
25
- --border: oklch(0.922 0 0);
26
- --input: oklch(0.922 0 0);
27
- --ring: oklch(0.708 0 0);
28
- --chart-1: oklch(0.809 0.105 251.813);
29
- --chart-2: oklch(0.623 0.214 259.815);
30
- --chart-3: oklch(0.546 0.245 262.881);
31
- --chart-4: oklch(0.488 0.243 264.376);
32
- --chart-5: oklch(0.424 0.199 265.638);
33
- --radius: 0.625rem;
34
- --sidebar: oklch(0.985 0 0);
35
- --sidebar-foreground: oklch(0.145 0 0);
36
- --sidebar-primary: oklch(0.205 0 0);
37
- --sidebar-primary-foreground: oklch(0.985 0 0);
38
- --sidebar-accent: oklch(0.97 0 0);
39
- --sidebar-accent-foreground: oklch(0.205 0 0);
40
- --sidebar-border: oklch(0.922 0 0);
41
- --sidebar-ring: oklch(0.708 0 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
43
 
44
  .dark {
45
- --background: oklch(0.145 0 0);
46
- --foreground: oklch(0.985 0 0);
47
- --card: oklch(0.205 0 0);
48
- --card-foreground: oklch(0.985 0 0);
49
- --popover: oklch(0.205 0 0);
50
- --popover-foreground: oklch(0.985 0 0);
51
- --primary: oklch(0.87 0 0);
52
- --primary-foreground: oklch(0.205 0 0);
53
- --secondary: oklch(0.269 0 0);
54
- --secondary-foreground: oklch(0.985 0 0);
55
- --muted: oklch(0.269 0 0);
56
- --muted-foreground: oklch(0.708 0 0);
57
- --accent: oklch(0.371 0 0);
58
- --accent-foreground: oklch(0.985 0 0);
59
- --destructive: oklch(0.704 0.191 22.216);
60
- --border: oklch(1 0 0 / 10%);
61
- --input: oklch(1 0 0 / 15%);
62
- --ring: oklch(0.556 0 0);
63
- --chart-1: oklch(0.809 0.105 251.813);
64
- --chart-2: oklch(0.623 0.214 259.815);
65
- --chart-3: oklch(0.546 0.245 262.881);
66
- --chart-4: oklch(0.488 0.243 264.376);
67
- --chart-5: oklch(0.424 0.199 265.638);
68
- --sidebar: oklch(0.205 0 0);
69
- --sidebar-foreground: oklch(0.985 0 0);
70
- --sidebar-primary: oklch(0.488 0.243 264.376);
71
- --sidebar-primary-foreground: oklch(0.985 0 0);
72
- --sidebar-accent: oklch(0.269 0 0);
73
- --sidebar-accent-foreground: oklch(0.985 0 0);
74
- --sidebar-border: oklch(1 0 0 / 10%);
75
- --sidebar-ring: oklch(0.556 0 0);
 
 
 
76
  }
77
 
78
  @theme inline {
79
- --font-sans: "Inter Variable", sans-serif;
80
- --color-sidebar-ring: var(--sidebar-ring);
81
- --color-sidebar-border: var(--sidebar-border);
82
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
83
- --color-sidebar-accent: var(--sidebar-accent);
84
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
85
- --color-sidebar-primary: var(--sidebar-primary);
86
- --color-sidebar-foreground: var(--sidebar-foreground);
87
- --color-sidebar: var(--sidebar);
88
- --color-chart-5: var(--chart-5);
89
- --color-chart-4: var(--chart-4);
90
- --color-chart-3: var(--chart-3);
91
- --color-chart-2: var(--chart-2);
92
- --color-chart-1: var(--chart-1);
93
- --color-ring: var(--ring);
94
- --color-input: var(--input);
95
- --color-border: var(--border);
96
- --color-destructive: var(--destructive);
97
- --color-accent-foreground: var(--accent-foreground);
98
- --color-accent: var(--accent);
99
- --color-muted-foreground: var(--muted-foreground);
100
- --color-muted: var(--muted);
101
- --color-secondary-foreground: var(--secondary-foreground);
102
- --color-secondary: var(--secondary);
103
- --color-primary-foreground: var(--primary-foreground);
104
- --color-primary: var(--primary);
105
- --color-popover-foreground: var(--popover-foreground);
106
- --color-popover: var(--popover);
107
- --color-card-foreground: var(--card-foreground);
108
- --color-card: var(--card);
109
- --color-foreground: var(--foreground);
110
  --color-background: var(--background);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  --radius-sm: calc(var(--radius) - 4px);
112
- --radius-md: calc(var(--radius) - 2px);
113
- --radius-lg: var(--radius);
114
- --radius-xl: calc(var(--radius) + 4px);
115
- --radius-2xl: calc(var(--radius) + 8px);
116
- --radius-3xl: calc(var(--radius) + 12px);
117
- --radius-4xl: calc(var(--radius) + 16px);
 
118
  }
119
 
120
  @layer base {
@@ -122,9 +289,61 @@
122
  @apply border-border outline-ring/50;
123
  }
124
  body {
125
- @apply font-sans bg-background text-foreground;
126
  }
127
  html {
128
  @apply font-sans;
129
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap');
2
  @import "tailwindcss";
3
  @import "tw-animate-css";
4
  @import "shadcn/tailwind.css";
5
+
6
  @source "../../../apps/**/*.{ts,tsx}";
7
  @source "../**/*.{ts,tsx}";
8
 
9
  @custom-variant dark (&:is(.dark *));
10
 
11
+ /* ── Clay-Inspired Design Tokens ────────────────────────── */
12
+
13
  :root {
14
+ /* Primary */
15
+ --clay-black: #000000;
16
+ --pure-white: #ffffff;
17
+ --warm-cream: #faf9f7;
18
+
19
+ /* Swatch Palette */
20
+ --matcha-300: #84e7a5;
21
+ --matcha-600: #078a52;
22
+ --matcha-800: #02492a;
23
+ --slushie-500: #3bd3fd;
24
+ --slushie-800: #0089ad;
25
+ --lemon-400: #f8cc65;
26
+ --lemon-500: #fbbd41;
27
+ --lemon-700: #d08a11;
28
+ --lemon-800: #9d6a09;
29
+ --ube-300: #c1b0ff;
30
+ --ube-800: #43089f;
31
+ --ube-900: #32037d;
32
+ --pomegranate-400: #fc7981;
33
+ --blueberry-800: #01418d;
34
+ --dragonfruit-400: #e84393;
35
+
36
+ /* Neutral Scale (Warm) */
37
+ --warm-silver: #9f9b93;
38
+ --warm-charcoal: #55534e;
39
+ --dark-charcoal: #333333;
40
+
41
+ /* Surface & Border */
42
+ --oat-border: #dad4c8;
43
+ --oat-light: #eee9df;
44
+ --cool-border: #e6e8ec;
45
+ --dark-border: #525a69;
46
+ --light-frost: #eff1f3;
47
+
48
+ /* Badges */
49
+ --badge-blue-bg: #f0f8ff;
50
+ --badge-blue-text: #3859f9;
51
+ --focus-ring: rgb(20, 110, 245);
52
+
53
+ /* ── Semantic Mapping ─────────────────────────────────── */
54
+
55
+ --background: var(--warm-cream);
56
+ --foreground: var(--clay-black);
57
+ --card: var(--pure-white);
58
+ --card-foreground: var(--clay-black);
59
+ --popover: var(--pure-white);
60
+ --popover-foreground: var(--clay-black);
61
+ --primary: var(--clay-black);
62
+ --primary-foreground: var(--pure-white);
63
+ --secondary: var(--matcha-600);
64
+ --secondary-foreground: var(--pure-white);
65
+ --muted: var(--oat-light);
66
+ --muted-foreground: var(--warm-silver);
67
+ --accent: var(--slushie-500);
68
+ --accent-foreground: var(--clay-black);
69
+ --destructive: var(--pomegranate-400);
70
+ --destructive-foreground: var(--pure-white);
71
+ --border: var(--oat-border);
72
+ --input: var(--oat-border);
73
+ --ring: var(--focus-ring);
74
+
75
+ /* Surface tokens */
76
+ --surface: var(--warm-cream);
77
+ --surface-bright: var(--pure-white);
78
+ --surface-dim: var(--oat-light);
79
+ --surface-variant: var(--oat-light);
80
+ --surface-container: var(--pure-white);
81
+ --surface-container-low: var(--oat-light);
82
+ --surface-container-lowest: var(--pure-white);
83
+ --surface-container-high: var(--oat-border);
84
+ --surface-container-highest: var(--warm-silver);
85
+ --on-surface: var(--clay-black);
86
+ --on-surface-variant: var(--warm-charcoal);
87
+ --on-background: var(--clay-black);
88
+
89
+ /* Container tokens */
90
+ --primary-container: var(--blueberry-800);
91
+ --secondary-container: var(--matcha-300);
92
+ --tertiary-container: var(--ube-300);
93
+ --error-container: var(--badge-blue-bg);
94
+ --on-primary-container: var(--pure-white);
95
+ --on-secondary-container: var(--matcha-800);
96
+ --on-tertiary-container: var(--ube-900);
97
+ --on-error-container: var(--badge-blue-text);
98
+
99
+ /* Outline */
100
+ --outline: var(--warm-charcoal);
101
+ --outline-variant: var(--oat-border);
102
+ --inverse-surface: var(--clay-black);
103
+ --inverse-primary: var(--pure-white);
104
+ --inverse-on-surface: var(--warm-cream);
105
+
106
+ /* Fixed colors */
107
+ --primary-fixed: var(--oat-light);
108
+ --primary-fixed-dim: var(--oat-border);
109
+ --secondary-fixed: var(--matcha-300);
110
+ --secondary-fixed-dim: var(--matcha-600);
111
+ --tertiary-fixed: var(--ube-300);
112
+ --tertiary-fixed-dim: var(--ube-800);
113
+
114
+ /* Chart */
115
+ --chart-1: var(--matcha-600);
116
+ --chart-2: var(--slushie-500);
117
+ --chart-3: var(--lemon-500);
118
+ --chart-4: var(--ube-800);
119
+ --chart-5: var(--pomegranate-400);
120
+
121
+ /* Sidebar */
122
+ --sidebar: var(--oat-light);
123
+ --sidebar-foreground: var(--clay-black);
124
+ --sidebar-primary: var(--clay-black);
125
+ --sidebar-primary-foreground: var(--pure-white);
126
+ --sidebar-accent: var(--oat-border);
127
+ --sidebar-accent-foreground: var(--clay-black);
128
+ --sidebar-border: var(--oat-border);
129
+ --sidebar-ring: var(--focus-ring);
130
+
131
+ /* Radius: Clay scale */
132
+ --radius: 0.75rem;
133
  }
134
 
135
  .dark {
136
+ --background: #1a1917;
137
+ --foreground: var(--pure-white);
138
+ --card: #2a2927;
139
+ --card-foreground: var(--pure-white);
140
+ --popover: #2a2927;
141
+ --popover-foreground: var(--pure-white);
142
+ --primary: var(--pure-white);
143
+ --primary-foreground: var(--clay-black);
144
+ --secondary: var(--matcha-300);
145
+ --secondary-foreground: var(--clay-black);
146
+ --muted: #3a3937;
147
+ --muted-foreground: var(--warm-silver);
148
+ --border: #4a4947;
149
+ --input: #4a4947;
150
+ --ring: var(--slushie-500);
151
+ --surface: #1a1917;
152
+ --surface-container: #2a2927;
153
+ --surface-container-low: #222120;
154
+ --surface-container-high: #3a3937;
155
+ --on-surface: var(--pure-white);
156
+ --on-surface-variant: var(--warm-silver);
157
+ --primary-container: var(--slushie-800);
158
+ --secondary-container: var(--matcha-800);
159
+ --tertiary-container: var(--ube-900);
160
+ --on-primary-container: var(--clay-black);
161
+ --on-secondary-container: var(--matcha-300);
162
+ --on-tertiary-container: var(--ube-300);
163
+ --sidebar: #1a1917;
164
+ --sidebar-foreground: var(--pure-white);
165
+ --sidebar-primary: var(--slushie-500);
166
+ --sidebar-primary-foreground: var(--clay-black);
167
+ --sidebar-accent: #3a3937;
168
+ --sidebar-accent-foreground: var(--pure-white);
169
+ --sidebar-border: #4a4947;
170
  }
171
 
172
  @theme inline {
173
+ --font-sans: "Inter", system-ui, sans-serif;
174
+ --font-headline: "Inter", system-ui, sans-serif;
175
+ --font-mono: "Space Mono", ui-monospace, monospace;
176
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  --color-background: var(--background);
178
+ --color-foreground: var(--foreground);
179
+ --color-card: var(--card);
180
+ --color-card-foreground: var(--card-foreground);
181
+ --color-popover: var(--popover);
182
+ --color-popover-foreground: var(--popover-foreground);
183
+ --color-primary: var(--primary);
184
+ --color-primary-foreground: var(--primary-foreground);
185
+ --color-secondary: var(--secondary);
186
+ --color-secondary-foreground: var(--secondary-foreground);
187
+ --color-muted: var(--muted);
188
+ --color-muted-foreground: var(--muted-foreground);
189
+ --color-accent: var(--accent);
190
+ --color-accent-foreground: var(--accent-foreground);
191
+ --color-destructive: var(--destructive);
192
+ --color-destructive-foreground: var(--destructive-foreground);
193
+ --color-border: var(--border);
194
+ --color-input: var(--input);
195
+ --color-ring: var(--ring);
196
+
197
+ /* Surface semantic tokens */
198
+ --color-surface: var(--surface);
199
+ --color-surface-bright: var(--surface-bright);
200
+ --color-surface-dim: var(--surface-dim);
201
+ --color-surface-variant: var(--surface-variant);
202
+ --color-surface-container: var(--surface-container);
203
+ --color-surface-container-low: var(--surface-container-low);
204
+ --color-surface-container-lowest: var(--surface-container-lowest);
205
+ --color-surface-container-high: var(--surface-container-high);
206
+ --color-surface-container-highest: var(--surface-container-highest);
207
+ --color-on-surface: var(--on-surface);
208
+ --color-on-surface-variant: var(--on-surface-variant);
209
+ --color-on-background: var(--on-background);
210
+
211
+ /* On-brand text */
212
+ --color-on-primary: var(--primary-foreground);
213
+ --color-on-secondary: var(--secondary-foreground);
214
+ --color-on-tertiary: var(--tertiary-foreground);
215
+ --color-on-error: var(--destructive-foreground);
216
+
217
+ /* M3 container tokens */
218
+ --color-primary-container: var(--primary-container);
219
+ --color-secondary-container: var(--secondary-container);
220
+ --color-tertiary-container: var(--tertiary-container);
221
+ --color-error-container: var(--error-container);
222
+ --color-on-primary-container: var(--on-primary-container);
223
+ --color-on-secondary-container: var(--on-secondary-container);
224
+ --color-on-tertiary-container: var(--on-tertiary-container);
225
+ --color-on-error-container: var(--on-error-container);
226
+
227
+ --color-outline: var(--outline);
228
+ --color-outline-variant: var(--outline-variant);
229
+ --color-inverse-surface: var(--inverse-surface);
230
+ --color-inverse-primary: var(--inverse-primary);
231
+ --color-inverse-on-surface: var(--inverse-on-surface);
232
+
233
+ /* Fixed colors */
234
+ --color-primary-fixed: var(--primary-fixed);
235
+ --color-primary-fixed-dim: var(--primary-fixed-dim);
236
+ --color-secondary-fixed: var(--secondary-fixed);
237
+ --color-secondary-fixed-dim: var(--secondary-fixed-dim);
238
+ --color-tertiary-fixed: var(--tertiary-fixed);
239
+ --color-tertiary-fixed-dim: var(--tertiary-fixed-dim);
240
+
241
+ /* Swatch colors */
242
+ --color-matcha-300: var(--matcha-300);
243
+ --color-matcha-600: var(--matcha-600);
244
+ --color-matcha-800: var(--matcha-800);
245
+ --color-slushie-500: var(--slushie-500);
246
+ --color-slushie-800: var(--slushie-800);
247
+ --color-lemon-400: var(--lemon-400);
248
+ --color-lemon-500: var(--lemon-500);
249
+ --color-lemon-700: var(--lemon-700);
250
+ --color-lemon-800: var(--lemon-800);
251
+ --color-ube-300: var(--ube-300);
252
+ --color-ube-800: var(--ube-800);
253
+ --color-ube-900: var(--ube-900);
254
+ --color-pomegranate-400: var(--pomegranate-400);
255
+ --color-blueberry-800: var(--blueberry-800);
256
+ --color-dragonfruit-400: var(--dragonfruit-400);
257
+ --color-oat-border: var(--oat-border);
258
+ --color-oat-light: var(--oat-light);
259
+ --color-warm-silver: var(--warm-silver);
260
+ --color-warm-charcoal: var(--warm-charcoal);
261
+
262
+ --color-chart-1: var(--chart-1);
263
+ --color-chart-2: var(--chart-2);
264
+ --color-chart-3: var(--chart-3);
265
+ --color-chart-4: var(--chart-4);
266
+ --color-chart-5: var(--chart-5);
267
+
268
+ --color-sidebar: var(--sidebar);
269
+ --color-sidebar-foreground: var(--sidebar-foreground);
270
+ --color-sidebar-primary: var(--sidebar-primary);
271
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
272
+ --color-sidebar-accent: var(--sidebar-accent);
273
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
274
+ --color-sidebar-border: var(--sidebar-border);
275
+ --color-sidebar-ring: var(--sidebar-ring);
276
+
277
  --radius-sm: calc(var(--radius) - 4px);
278
+ --radius-md: var(--radius);
279
+ --radius-lg: calc(var(--radius) + 4px);
280
+ --radius-xl: calc(var(--radius) + 8px);
281
+ --radius-2xl: calc(var(--radius) + 16px);
282
+ --radius-3xl: calc(var(--radius) + 24px);
283
+ --radius-4xl: calc(var(--radius) + 32px);
284
+ --radius-pill: 1584px;
285
  }
286
 
287
  @layer base {
 
289
  @apply border-border outline-ring/50;
290
  }
291
  body {
292
+ @apply bg-background text-foreground font-sans antialiased;
293
  }
294
  html {
295
  @apply font-sans;
296
  }
297
+ h1, h2, h3, h4, h5, h6 {
298
+ font-family: var(--font-headline);
299
+ font-weight: 600;
300
+ letter-spacing: -0.02em;
301
+ }
302
+ }
303
+
304
+ /* ── Utility Classes ─────────────────────────────────────── */
305
+
306
+ @layer utilities {
307
+ .font-headline {
308
+ font-family: var(--font-headline);
309
+ font-weight: 600;
310
+ letter-spacing: -0.02em;
311
+ }
312
+ .font-body {
313
+ font-family: var(--font-sans);
314
+ }
315
+ .font-mono {
316
+ font-family: var(--font-mono);
317
+ }
318
+ .glass-panel {
319
+ background: rgba(250, 249, 247, 0.8);
320
+ backdrop-filter: blur(12px);
321
+ -webkit-backdrop-filter: blur(12px);
322
+ }
323
+ .ai-glow {
324
+ background: radial-gradient(circle at 50% 50%, rgba(132, 231, 165, 0.15) 0%, transparent 70%);
325
+ }
326
+ .scrollbar-hide {
327
+ -ms-overflow-style: none;
328
+ scrollbar-width: none;
329
+ }
330
+ .scrollbar-hide::-webkit-scrollbar {
331
+ display: none;
332
+ }
333
+ .clay-shadow {
334
+ box-shadow: rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px 1px inset, rgba(0,0,0,0.05) 0px -0.5px 1px;
335
+ }
336
+ .clay-hover {
337
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
338
+ }
339
+ .clay-hover:hover {
340
+ transform: rotateZ(-2deg) translateY(-2px);
341
+ box-shadow: rgb(0,0,0) -4px 4px;
342
+ }
343
+ .uppercase-label {
344
+ font-size: 0.75rem;
345
+ font-weight: 600;
346
+ text-transform: uppercase;
347
+ letter-spacing: 0.0675em;
348
+ }
349
  }
skills-lock.json CHANGED
@@ -16,6 +16,21 @@
16
  "sourceType": "github",
17
  "computedHash": "2bc6cf38ec7b4a5778a43fabb3e87642c9897ad45e9eb42438b57fd2def6cca8"
18
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  "turborepo": {
20
  "source": "vercel/turborepo",
21
  "sourceType": "github",
 
16
  "sourceType": "github",
17
  "computedHash": "2bc6cf38ec7b4a5778a43fabb3e87642c9897ad45e9eb42438b57fd2def6cca8"
18
  },
19
+ "tanstack-query-best-practices": {
20
+ "source": "deckardger/tanstack-agent-skills",
21
+ "sourceType": "github",
22
+ "computedHash": "21d2e8b7468a81d772b6527ad8ca178cc2f3f08af777bb9061333013ee88a0c9"
23
+ },
24
+ "tanstack-start-best-practices": {
25
+ "source": "deckardger/tanstack-agent-skills",
26
+ "sourceType": "github",
27
+ "computedHash": "8b073187405a10f9997158f9139a9664cc801595d2fe63d542a0bac9bf2152f5"
28
+ },
29
+ "to-prd": {
30
+ "source": "mattpocock/skills",
31
+ "sourceType": "github",
32
+ "computedHash": "8351452ad372ea4f839d3f7b951927b417851ffa49e7e6af28ea0f0ae111dca8"
33
+ },
34
  "turborepo": {
35
  "source": "vercel/turborepo",
36
  "sourceType": "github",