rogasper commited on
Commit
f90228b
Β·
1 Parent(s): 9cee966

Enhance application by adding new routes for jobs and attempt details, updating sidebar navigation, and implementing combo package builder functionality. Also, include new dependencies in the lock files and modify API key structure to support maxTokens.

Browse files
apps/server/src/index.ts CHANGED
@@ -7,6 +7,9 @@ import { Hono } from "hono";
7
  import { cors } from "hono/cors";
8
  import { logger } from "hono/logger";
9
 
 
 
 
10
  const app = new Hono();
11
 
12
  app.use(logger());
 
7
  import { cors } from "hono/cors";
8
  import { logger } from "hono/logger";
9
 
10
+ // Side-effect: start background job worker
11
+ import "@labas/api/queue";
12
+
13
  const app = new Hono();
14
 
15
  app.use(logger());
apps/web/src/components/sidebar.tsx CHANGED
@@ -5,9 +5,10 @@ import { useSidebar } from "@/hooks/use-sidebar";
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: "/packages", label: "Paket", icon: "folder" },
10
- { to: "/builder", label: "Builder", icon: "construction" },
11
  { to: "/analytics", label: "Analytics", icon: "analytics" },
12
  ];
13
 
@@ -46,7 +47,7 @@ export function Sidebar() {
46
 
47
  <nav className="flex-1 space-y-1 w-full">
48
  {navItems.map((item) => {
49
- const isActive = location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
50
  return (
51
  <Link
52
  key={item.to}
 
5
  const navItems = [
6
  { to: "/", label: "Dashboard", icon: "dashboard" },
7
  { to: "/generate", label: "AI Lab", icon: "auto_awesome" },
8
+ { to: "/jobs", label: "Jobs", icon: "schedule" },
9
  { to: "/bank", label: "Bank Soal", icon: "database" },
10
  { to: "/packages", label: "Paket", icon: "folder" },
11
+ { to: "/builder/combo", label: "Combo", icon: "join_inner" },
12
  { to: "/analytics", label: "Analytics", icon: "analytics" },
13
  ];
14
 
 
47
 
48
  <nav className="flex-1 space-y-1 w-full">
49
  {navItems.map((item) => {
50
+ const isActive = location.pathname === item.to || (location.pathname.startsWith(`${item.to}/`) && item.to !== "/builder");
51
  return (
52
  <Link
53
  key={item.to}
apps/web/src/hooks/use-api-key.ts CHANGED
@@ -8,6 +8,7 @@ export interface StoredApiKey {
8
  baseUrl: string;
9
  apiKey: string;
10
  modelName: string;
 
11
  }
12
 
13
  export function useApiKey() {
 
8
  baseUrl: string;
9
  apiKey: string;
10
  modelName: string;
11
+ maxTokens?: number;
12
  }
13
 
14
  export function useApiKey() {
apps/web/src/routeTree.gen.ts CHANGED
@@ -12,12 +12,14 @@ import { Route as rootRouteImport } from './routes/__root'
12
  import { Route as SettingsRouteImport } from './routes/settings'
13
  import { Route as PackagesRouteImport } from './routes/packages'
14
  import { Route as LoginRouteImport } from './routes/login'
 
15
  import { Route as GenerateRouteImport } from './routes/generate'
16
- import { Route as BuilderRouteImport } from './routes/builder'
17
  import { Route as BankRouteImport } from './routes/bank'
18
  import { Route as IndexRouteImport } from './routes/index'
19
  import { Route as PackageIdRouteImport } from './routes/package.$id'
20
- import { Route as BankIdRouteImport } from './routes/bank.$id'
 
 
21
 
22
  const SettingsRoute = SettingsRouteImport.update({
23
  id: '/settings',
@@ -34,16 +36,16 @@ const LoginRoute = LoginRouteImport.update({
34
  path: '/login',
35
  getParentRoute: () => rootRouteImport,
36
  } as any)
 
 
 
 
 
37
  const GenerateRoute = GenerateRouteImport.update({
38
  id: '/generate',
39
  path: '/generate',
40
  getParentRoute: () => rootRouteImport,
41
  } as any)
42
- const BuilderRoute = BuilderRouteImport.update({
43
- id: '/builder',
44
- path: '/builder',
45
- getParentRoute: () => rootRouteImport,
46
- } as any)
47
  const BankRoute = BankRouteImport.update({
48
  id: '/bank',
49
  path: '/bank',
@@ -59,91 +61,114 @@ const PackageIdRoute = PackageIdRouteImport.update({
59
  path: '/package/$id',
60
  getParentRoute: () => rootRouteImport,
61
  } as any)
62
- const BankIdRoute = BankIdRouteImport.update({
63
- id: '/$id',
64
- path: '/$id',
65
- getParentRoute: () => BankRoute,
 
 
 
 
 
 
 
 
 
 
66
  } as any)
67
 
68
  export interface FileRoutesByFullPath {
69
  '/': typeof IndexRoute
70
- '/bank': typeof BankRouteWithChildren
71
- '/builder': typeof BuilderRoute
72
  '/generate': typeof GenerateRoute
 
73
  '/login': typeof LoginRoute
74
  '/packages': typeof PackagesRoute
75
  '/settings': typeof SettingsRoute
76
- '/bank/$id': typeof BankIdRoute
77
- '/package/$id': typeof PackageIdRoute
 
 
78
  }
79
  export interface FileRoutesByTo {
80
  '/': typeof IndexRoute
81
- '/bank': typeof BankRouteWithChildren
82
- '/builder': typeof BuilderRoute
83
  '/generate': typeof GenerateRoute
 
84
  '/login': typeof LoginRoute
85
  '/packages': typeof PackagesRoute
86
  '/settings': typeof SettingsRoute
87
- '/bank/$id': typeof BankIdRoute
88
- '/package/$id': typeof PackageIdRoute
 
 
89
  }
90
  export interface FileRoutesById {
91
  __root__: typeof rootRouteImport
92
  '/': typeof IndexRoute
93
- '/bank': typeof BankRouteWithChildren
94
- '/builder': typeof BuilderRoute
95
  '/generate': typeof GenerateRoute
 
96
  '/login': typeof LoginRoute
97
  '/packages': typeof PackagesRoute
98
  '/settings': typeof SettingsRoute
99
- '/bank/$id': typeof BankIdRoute
100
- '/package/$id': typeof PackageIdRoute
 
 
101
  }
102
  export interface FileRouteTypes {
103
  fileRoutesByFullPath: FileRoutesByFullPath
104
  fullPaths:
105
  | '/'
106
  | '/bank'
107
- | '/builder'
108
  | '/generate'
 
109
  | '/login'
110
  | '/packages'
111
  | '/settings'
112
- | '/bank/$id'
 
113
  | '/package/$id'
 
114
  fileRoutesByTo: FileRoutesByTo
115
  to:
116
  | '/'
117
  | '/bank'
118
- | '/builder'
119
  | '/generate'
 
120
  | '/login'
121
  | '/packages'
122
  | '/settings'
123
- | '/bank/$id'
 
124
  | '/package/$id'
 
125
  id:
126
  | '__root__'
127
  | '/'
128
  | '/bank'
129
- | '/builder'
130
  | '/generate'
 
131
  | '/login'
132
  | '/packages'
133
  | '/settings'
134
- | '/bank/$id'
 
135
  | '/package/$id'
 
136
  fileRoutesById: FileRoutesById
137
  }
138
  export interface RootRouteChildren {
139
  IndexRoute: typeof IndexRoute
140
- BankRoute: typeof BankRouteWithChildren
141
- BuilderRoute: typeof BuilderRoute
142
  GenerateRoute: typeof GenerateRoute
 
143
  LoginRoute: typeof LoginRoute
144
  PackagesRoute: typeof PackagesRoute
145
  SettingsRoute: typeof SettingsRoute
146
- PackageIdRoute: typeof PackageIdRoute
 
147
  }
148
 
149
  declare module '@tanstack/react-router' {
@@ -169,6 +194,13 @@ declare module '@tanstack/react-router' {
169
  preLoaderRoute: typeof LoginRouteImport
170
  parentRoute: typeof rootRouteImport
171
  }
 
 
 
 
 
 
 
172
  '/generate': {
173
  id: '/generate'
174
  path: '/generate'
@@ -176,13 +208,6 @@ declare module '@tanstack/react-router' {
176
  preLoaderRoute: typeof GenerateRouteImport
177
  parentRoute: typeof rootRouteImport
178
  }
179
- '/builder': {
180
- id: '/builder'
181
- path: '/builder'
182
- fullPath: '/builder'
183
- preLoaderRoute: typeof BuilderRouteImport
184
- parentRoute: typeof rootRouteImport
185
- }
186
  '/bank': {
187
  id: '/bank'
188
  path: '/bank'
@@ -204,35 +229,52 @@ declare module '@tanstack/react-router' {
204
  preLoaderRoute: typeof PackageIdRouteImport
205
  parentRoute: typeof rootRouteImport
206
  }
207
- '/bank/$id': {
208
- id: '/bank/$id'
209
- path: '/$id'
210
- fullPath: '/bank/$id'
211
- preLoaderRoute: typeof BankIdRouteImport
212
- parentRoute: typeof BankRoute
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  }
214
  }
215
  }
216
 
217
- interface BankRouteChildren {
218
- BankIdRoute: typeof BankIdRoute
219
  }
220
 
221
- const BankRouteChildren: BankRouteChildren = {
222
- BankIdRoute: BankIdRoute,
223
  }
224
 
225
- const BankRouteWithChildren = BankRoute._addFileChildren(BankRouteChildren)
 
 
226
 
227
  const rootRouteChildren: RootRouteChildren = {
228
  IndexRoute: IndexRoute,
229
- BankRoute: BankRouteWithChildren,
230
- BuilderRoute: BuilderRoute,
231
  GenerateRoute: GenerateRoute,
 
232
  LoginRoute: LoginRoute,
233
  PackagesRoute: PackagesRoute,
234
  SettingsRoute: SettingsRoute,
235
- PackageIdRoute: PackageIdRoute,
 
236
  }
237
  export const routeTree = rootRouteImport
238
  ._addFileChildren(rootRouteChildren)
 
12
  import { Route as SettingsRouteImport } from './routes/settings'
13
  import { Route as PackagesRouteImport } from './routes/packages'
14
  import { Route as LoginRouteImport } from './routes/login'
15
+ import { Route as JobsRouteImport } from './routes/jobs'
16
  import { Route as GenerateRouteImport } from './routes/generate'
 
17
  import { Route as BankRouteImport } from './routes/bank'
18
  import { Route as IndexRouteImport } from './routes/index'
19
  import { Route as PackageIdRouteImport } from './routes/package.$id'
20
+ import { Route as BuilderComboRouteImport } from './routes/builder.combo'
21
+ import { Route as AttemptIdRouteImport } from './routes/attempt.$id'
22
+ import { Route as PackageIdTakeRouteImport } from './routes/package.$id.take'
23
 
24
  const SettingsRoute = SettingsRouteImport.update({
25
  id: '/settings',
 
36
  path: '/login',
37
  getParentRoute: () => rootRouteImport,
38
  } as any)
39
+ const JobsRoute = JobsRouteImport.update({
40
+ id: '/jobs',
41
+ path: '/jobs',
42
+ getParentRoute: () => rootRouteImport,
43
+ } as any)
44
  const GenerateRoute = GenerateRouteImport.update({
45
  id: '/generate',
46
  path: '/generate',
47
  getParentRoute: () => rootRouteImport,
48
  } as any)
 
 
 
 
 
49
  const BankRoute = BankRouteImport.update({
50
  id: '/bank',
51
  path: '/bank',
 
61
  path: '/package/$id',
62
  getParentRoute: () => rootRouteImport,
63
  } as any)
64
+ const BuilderComboRoute = BuilderComboRouteImport.update({
65
+ id: '/combo',
66
+ path: '/combo',
67
+ getParentRoute: () => BuilderRoute,
68
+ } as any)
69
+ const AttemptIdRoute = AttemptIdRouteImport.update({
70
+ id: '/attempt/$id',
71
+ path: '/attempt/$id',
72
+ getParentRoute: () => rootRouteImport,
73
+ } as any)
74
+ const PackageIdTakeRoute = PackageIdTakeRouteImport.update({
75
+ id: '/take',
76
+ path: '/take',
77
+ getParentRoute: () => PackageIdRoute,
78
  } as any)
79
 
80
  export interface FileRoutesByFullPath {
81
  '/': typeof IndexRoute
82
+ '/bank': typeof BankRoute
 
83
  '/generate': typeof GenerateRoute
84
+ '/jobs': typeof JobsRoute
85
  '/login': typeof LoginRoute
86
  '/packages': typeof PackagesRoute
87
  '/settings': typeof SettingsRoute
88
+ '/attempt/$id': typeof AttemptIdRoute
89
+ '/builder/combo': typeof BuilderComboRoute
90
+ '/package/$id': typeof PackageIdRouteWithChildren
91
+ '/package/$id/take': typeof PackageIdTakeRoute
92
  }
93
  export interface FileRoutesByTo {
94
  '/': typeof IndexRoute
95
+ '/bank': typeof BankRoute
 
96
  '/generate': typeof GenerateRoute
97
+ '/jobs': typeof JobsRoute
98
  '/login': typeof LoginRoute
99
  '/packages': typeof PackagesRoute
100
  '/settings': typeof SettingsRoute
101
+ '/attempt/$id': typeof AttemptIdRoute
102
+ '/builder/combo': typeof BuilderComboRoute
103
+ '/package/$id': typeof PackageIdRouteWithChildren
104
+ '/package/$id/take': typeof PackageIdTakeRoute
105
  }
106
  export interface FileRoutesById {
107
  __root__: typeof rootRouteImport
108
  '/': typeof IndexRoute
109
+ '/bank': typeof BankRoute
 
110
  '/generate': typeof GenerateRoute
111
+ '/jobs': typeof JobsRoute
112
  '/login': typeof LoginRoute
113
  '/packages': typeof PackagesRoute
114
  '/settings': typeof SettingsRoute
115
+ '/attempt/$id': typeof AttemptIdRoute
116
+ '/builder/combo': typeof BuilderComboRoute
117
+ '/package/$id': typeof PackageIdRouteWithChildren
118
+ '/package/$id/take': typeof PackageIdTakeRoute
119
  }
120
  export interface FileRouteTypes {
121
  fileRoutesByFullPath: FileRoutesByFullPath
122
  fullPaths:
123
  | '/'
124
  | '/bank'
 
125
  | '/generate'
126
+ | '/jobs'
127
  | '/login'
128
  | '/packages'
129
  | '/settings'
130
+ | '/attempt/$id'
131
+ | '/builder/combo'
132
  | '/package/$id'
133
+ | '/package/$id/take'
134
  fileRoutesByTo: FileRoutesByTo
135
  to:
136
  | '/'
137
  | '/bank'
 
138
  | '/generate'
139
+ | '/jobs'
140
  | '/login'
141
  | '/packages'
142
  | '/settings'
143
+ | '/attempt/$id'
144
+ | '/builder/combo'
145
  | '/package/$id'
146
+ | '/package/$id/take'
147
  id:
148
  | '__root__'
149
  | '/'
150
  | '/bank'
 
151
  | '/generate'
152
+ | '/jobs'
153
  | '/login'
154
  | '/packages'
155
  | '/settings'
156
+ | '/attempt/$id'
157
+ | '/builder/combo'
158
  | '/package/$id'
159
+ | '/package/$id/take'
160
  fileRoutesById: FileRoutesById
161
  }
162
  export interface RootRouteChildren {
163
  IndexRoute: typeof IndexRoute
164
+ BankRoute: typeof BankRoute
 
165
  GenerateRoute: typeof GenerateRoute
166
+ JobsRoute: typeof JobsRoute
167
  LoginRoute: typeof LoginRoute
168
  PackagesRoute: typeof PackagesRoute
169
  SettingsRoute: typeof SettingsRoute
170
+ AttemptIdRoute: typeof AttemptIdRoute
171
+ PackageIdRoute: typeof PackageIdRouteWithChildren
172
  }
173
 
174
  declare module '@tanstack/react-router' {
 
194
  preLoaderRoute: typeof LoginRouteImport
195
  parentRoute: typeof rootRouteImport
196
  }
197
+ '/jobs': {
198
+ id: '/jobs'
199
+ path: '/jobs'
200
+ fullPath: '/jobs'
201
+ preLoaderRoute: typeof JobsRouteImport
202
+ parentRoute: typeof rootRouteImport
203
+ }
204
  '/generate': {
205
  id: '/generate'
206
  path: '/generate'
 
208
  preLoaderRoute: typeof GenerateRouteImport
209
  parentRoute: typeof rootRouteImport
210
  }
 
 
 
 
 
 
 
211
  '/bank': {
212
  id: '/bank'
213
  path: '/bank'
 
229
  preLoaderRoute: typeof PackageIdRouteImport
230
  parentRoute: typeof rootRouteImport
231
  }
232
+ '/builder/combo': {
233
+ id: '/builder/combo'
234
+ path: '/combo'
235
+ fullPath: '/builder/combo'
236
+ preLoaderRoute: typeof BuilderComboRouteImport
237
+ parentRoute: typeof BuilderRoute
238
+ }
239
+ '/attempt/$id': {
240
+ id: '/attempt/$id'
241
+ path: '/attempt/$id'
242
+ fullPath: '/attempt/$id'
243
+ preLoaderRoute: typeof AttemptIdRouteImport
244
+ parentRoute: typeof rootRouteImport
245
+ }
246
+ '/package/$id/take': {
247
+ id: '/package/$id/take'
248
+ path: '/take'
249
+ fullPath: '/package/$id/take'
250
+ preLoaderRoute: typeof PackageIdTakeRouteImport
251
+ parentRoute: typeof PackageIdRoute
252
  }
253
  }
254
  }
255
 
256
+ interface PackageIdRouteChildren {
257
+ PackageIdTakeRoute: typeof PackageIdTakeRoute
258
  }
259
 
260
+ const PackageIdRouteChildren: PackageIdRouteChildren = {
261
+ PackageIdTakeRoute: PackageIdTakeRoute,
262
  }
263
 
264
+ const PackageIdRouteWithChildren = PackageIdRoute._addFileChildren(
265
+ PackageIdRouteChildren,
266
+ )
267
 
268
  const rootRouteChildren: RootRouteChildren = {
269
  IndexRoute: IndexRoute,
270
+ BankRoute: BankRoute,
 
271
  GenerateRoute: GenerateRoute,
272
+ JobsRoute: JobsRoute,
273
  LoginRoute: LoginRoute,
274
  PackagesRoute: PackagesRoute,
275
  SettingsRoute: SettingsRoute,
276
+ AttemptIdRoute: AttemptIdRoute,
277
+ PackageIdRoute: PackageIdRouteWithChildren,
278
  }
279
  export const routeTree = rootRouteImport
280
  ._addFileChildren(rootRouteChildren)
apps/web/src/routes/attempt.$id.tsx ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("/attempt/$id")({
9
+ component: AttemptResultComponent,
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 = "" }: { name: string; className?: string }) {
20
+ return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
21
+ }
22
+
23
+ function formatTime(totalSeconds: number) {
24
+ const m = Math.floor(totalSeconds / 60);
25
+ const s = totalSeconds % 60;
26
+ return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
27
+ }
28
+
29
+ function AttemptResultComponent() {
30
+ const { id: attemptId } = Route.useParams();
31
+ const { data: session } = authClient.useSession();
32
+
33
+ const attemptQuery = useQuery(trpc.attempt.getById.queryOptions({ id: attemptId }));
34
+ const attempt = attemptQuery.data;
35
+
36
+ if (attemptQuery.isLoading) {
37
+ return (
38
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
39
+ <div className="h-8 w-48 bg-[var(--oat-light)] animate-pulse rounded mb-4" />
40
+ <div className="h-64 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ if (!attempt) {
46
+ return (
47
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
48
+ <div className="text-center py-20">
49
+ <MaterialIcon name="error_outline" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
50
+ <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Hasil tidak ditemukan</p>
51
+ <Link to="/packages" className="text-[var(--matcha-600)] font-semibold mt-4 inline-block">
52
+ Kembali ke Paket
53
+ </Link>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ const percentage = attempt.maxScore && attempt.maxScore > 0
60
+ ? Math.round(((attempt.totalScore ?? 0) / attempt.maxScore) * 100)
61
+ : 0;
62
+
63
+ const durationSec = attempt.finishedAt && attempt.startedAt
64
+ ? Math.round((new Date(attempt.finishedAt).getTime() - new Date(attempt.startedAt).getTime()) / 1000)
65
+ : 0;
66
+
67
+ return (
68
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
69
+ {/* Breadcrumb */}
70
+ <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-6">
71
+ <Link to="/packages" className="hover:text-[var(--clay-black)] transition-colors">
72
+ Paket
73
+ </Link>
74
+ <MaterialIcon name="chevron_right" className="text-xs" />
75
+ <span className="text-[var(--clay-black)] font-medium">Hasil Latihan</span>
76
+ </div>
77
+
78
+ {/* Score Header */}
79
+ <div className="mb-8">
80
+ <h1 className="text-3xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight mb-4">
81
+ Hasil Latihan
82
+ </h1>
83
+
84
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] overflow-hidden">
85
+ <CardContent className="p-6">
86
+ <div className="flex flex-col md:flex-row items-center gap-6">
87
+ {/* Big Score Circle */}
88
+ <div className="w-32 h-32 rounded-full border-4 border-[var(--matcha-500)] flex flex-col items-center justify-center bg-[var(--matcha-100)]">
89
+ <span className="text-3xl font-headline font-extrabold text-[var(--matcha-800)]">
90
+ {percentage}%
91
+ </span>
92
+ <span className="text-xs text-[var(--matcha-700)] font-medium">
93
+ {attempt.totalScore ?? 0}/{attempt.maxScore ?? 0}
94
+ </span>
95
+ </div>
96
+
97
+ <div className="flex-1 space-y-2 text-center md:text-left">
98
+ <p className="text-lg font-semibold text-[var(--clay-black)]">
99
+ {percentage >= 80
100
+ ? "Sempurna!"
101
+ : percentage >= 60
102
+ ? "Bagus, terus berlatih!"
103
+ : percentage >= 40
104
+ ? "Perlu latihan lebih"
105
+ : "Jangan menyerah, coba lagi!"}
106
+ </p>
107
+ <div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)] justify-center md:justify-start">
108
+ <span className="flex items-center gap-1">
109
+ <MaterialIcon name="timer" className="text-sm" />
110
+ {formatTime(durationSec)}
111
+ </span>
112
+ <span className="flex items-center gap-1">
113
+ <MaterialIcon name="quiz" className="text-sm" />
114
+ {attempt.maxScore ?? 0} soal
115
+ </span>
116
+ <span className="flex items-center gap-1">
117
+ <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
118
+ {attempt.totalScore ?? 0} benar
119
+ </span>
120
+ <span className="flex items-center gap-1">
121
+ <MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-500)]" />
122
+ {(attempt.maxScore ?? 0) - (attempt.totalScore ?? 0)} salah
123
+ </span>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </CardContent>
128
+ </Card>
129
+ </div>
130
+
131
+ {/* Section Breakdown */}
132
+ <div className="mb-8">
133
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
134
+ <div className="space-y-3">
135
+ {attempt.sections.map((sec: any) => {
136
+ const secPct = sec.maxScore && sec.maxScore > 0
137
+ ? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
138
+ : 0;
139
+ return (
140
+ <Card key={sec.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
141
+ <CardContent className="p-4">
142
+ <div className="flex items-center justify-between">
143
+ <div>
144
+ <p className="font-semibold text-[var(--clay-black)]">{sec.title}</p>
145
+ <p className="text-sm text-[var(--warm-charcoal)]">
146
+ {sec.score ?? 0}/{sec.maxScore ?? 0} benar
147
+ </p>
148
+ </div>
149
+ <div className="w-16 h-16 rounded-full border-2 border-[var(--matcha-400)] flex items-center justify-center bg-[var(--matcha-50)]">
150
+ <span className="text-lg font-bold text-[var(--matcha-700)]">{secPct}%</span>
151
+ </div>
152
+ </div>
153
+ </CardContent>
154
+ </Card>
155
+ );
156
+ })}
157
+ </div>
158
+ </div>
159
+
160
+ {/* Question Review */}
161
+ <div className="mb-8">
162
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Pembahasan Soal</h2>
163
+ <div className="space-y-4">
164
+ {attempt.sections.map((sec: any, secIdx: number) =>
165
+ sec.questions.map((q: any, qIdx: number) => {
166
+ const ans = sec.answers.find((a: any) => a.questionId === q.id);
167
+ const isCorrect = ans?.isCorrect;
168
+ const userAnswer = ans?.userAnswer ?? "Tidak dijawab";
169
+
170
+ return (
171
+ <Card
172
+ key={q.id}
173
+ className={`clay-shadow bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] ${
174
+ isCorrect === true
175
+ ? "border-[var(--matcha-400)]"
176
+ : isCorrect === false
177
+ ? "border-[var(--pomegranate-400)]"
178
+ : "border-[var(--oat-border)]"
179
+ }`}
180
+ >
181
+ <CardContent className="p-5">
182
+ <div className="flex items-start gap-3 mb-3">
183
+ <span
184
+ className={`w-8 h-8 rounded-full text-xs flex items-center justify-center font-bold shrink-0 ${
185
+ isCorrect === true
186
+ ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
187
+ : isCorrect === false
188
+ ? "bg-[var(--pomegranate-500)] text-[var(--pure-white)]"
189
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
190
+ }`}
191
+ >
192
+ {secIdx + 1}.{qIdx + 1}
193
+ </span>
194
+ <div className="flex-1">
195
+ <p className="text-[var(--clay-black)] font-medium">{q.questionText}</p>
196
+ <div className="flex gap-2 mt-1">
197
+ <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
198
+ {q.format.replace(/_/g, " ")}
199
+ </span>
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <div className="pl-11 space-y-2">
205
+ <div className="flex flex-wrap gap-4 text-sm">
206
+ <div>
207
+ <span className="text-[var(--warm-silver)]">Jawaban Anda:</span>{" "}
208
+ <span
209
+ className={`font-semibold ${
210
+ isCorrect === true
211
+ ? "text-[var(--matcha-700)]"
212
+ : isCorrect === false
213
+ ? "text-[var(--pomegranate-600)]"
214
+ : "text-[var(--warm-charcoal)]"
215
+ }`}
216
+ >
217
+ {userAnswer}
218
+ </span>
219
+ </div>
220
+ {(isCorrect === false || isCorrect === null) && (
221
+ <div>
222
+ <span className="text-[var(--warm-silver)]">Jawaban Benar:</span>{" "}
223
+ <span className="font-semibold text-[var(--matcha-700)]">
224
+ {q.correctAnswer}
225
+ </span>
226
+ </div>
227
+ )}
228
+ </div>
229
+
230
+ {q.explanation && (
231
+ <div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-3 text-sm text-[var(--warm-charcoal)]">
232
+ <span className="font-semibold text-[var(--clay-black)]">Penjelasan:</span>{" "}
233
+ {q.explanation}
234
+ </div>
235
+ )}
236
+ </div>
237
+ </CardContent>
238
+ </Card>
239
+ );
240
+ }),
241
+ )}
242
+ </div>
243
+ </div>
244
+
245
+ {/* Actions */}
246
+ <div className="flex gap-3">
247
+ <Link to="/package/$id/take" params={{ id: attempt.packageId! }}>
248
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
249
+ <MaterialIcon name="replay" />
250
+ <span className="ml-2">Coba Lagi</span>
251
+ </Button>
252
+ </Link>
253
+ <Link to="/packages">
254
+ <Button
255
+ variant="outline"
256
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
257
+ >
258
+ <MaterialIcon name="arrow_back" />
259
+ <span className="ml-2">Kembali ke Paket</span>
260
+ </Button>
261
+ </Link>
262
+ </div>
263
+ </div>
264
+ );
265
+ }
apps/web/src/routes/bank.$id.tsx DELETED
@@ -1,230 +0,0 @@
1
- import { useState } from "react";
2
- import { useQuery, 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 { Button } from "@labas/ui/components/button";
7
- import { Card, CardContent } from "@labas/ui/components/card";
8
-
9
- export const Route = createFileRoute("/bank/$id")({
10
- component: QuestionDetailComponent,
11
- beforeLoad: async () => {
12
- const session = await authClient.getSession();
13
- if (!session.data) {
14
- redirect({ to: "/login", throw: true });
15
- }
16
- return { session };
17
- },
18
- });
19
-
20
- function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
21
- return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
22
- }
23
-
24
- function formatLabel(fmt: string) {
25
- return fmt.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
26
- }
27
-
28
- function StarRating({
29
- value,
30
- onChange,
31
- readonly = false,
32
- }: {
33
- value: number | null;
34
- onChange?: (v: number) => void;
35
- readonly?: boolean;
36
- }) {
37
- const [hover, setHover] = useState<number | null>(null);
38
- return (
39
- <div className="flex gap-1">
40
- {[1, 2, 3, 4, 5].map((star) => (
41
- <button
42
- key={star}
43
- type="button"
44
- disabled={readonly}
45
- onClick={() => onChange?.(star)}
46
- onMouseEnter={() => !readonly && setHover(star)}
47
- onMouseLeave={() => setHover(null)}
48
- className={`transition-transform ${readonly ? "cursor-default" : "cursor-pointer hover:scale-110"}`}
49
- >
50
- <MaterialIcon
51
- name={(hover ?? value ?? 0) >= star ? "star" : "star_outline"}
52
- className={`text-xl ${(hover ?? value ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
53
- />
54
- </button>
55
- ))}
56
- </div>
57
- );
58
- }
59
-
60
- function QuestionDetailComponent() {
61
- const { id } = Route.useParams();
62
- const { data: session } = authClient.useSession();
63
-
64
- const questionQuery = useQuery(trpc.question.getById.queryOptions({ id }));
65
- const ratingQuery = useQuery(trpc.rating.getQuestionRating.queryOptions({ questionId: id }));
66
-
67
- const rateMutation = useMutation({
68
- ...trpc.rating.rateQuestion.mutationOptions(),
69
- onSuccess: () => {
70
- ratingQuery.refetch();
71
- questionQuery.refetch();
72
- },
73
- });
74
-
75
- const q = questionQuery.data;
76
-
77
- if (questionQuery.isLoading) {
78
- return (
79
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
80
- <div className="h-8 w-48 bg-[var(--oat-light)] animate-pulse rounded mb-4" />
81
- <div className="h-64 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
82
- </div>
83
- );
84
- }
85
-
86
- if (!q) {
87
- return (
88
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
89
- <div className="text-center py-20">
90
- <MaterialIcon name="error_outline" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
91
- <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Soal tidak ditemukan</p>
92
- <Link to="/bank" className="text-[var(--matcha-600)] font-semibold mt-4 inline-block">
93
- Kembali ke Bank Soal
94
- </Link>
95
- </div>
96
- </div>
97
- );
98
- }
99
-
100
- const isOwner = q.creatorUserId === session?.user.id;
101
-
102
- return (
103
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
104
- {/* Breadcrumb */}
105
- <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-6">
106
- <Link to="/bank" className="hover:text-[var(--clay-black)] transition-colors">Bank Soal</Link>
107
- <MaterialIcon name="chevron_right" className="text-xs" />
108
- <span className="text-[var(--clay-black)] font-medium">Detail Soal</span>
109
- </div>
110
-
111
- {/* Header */}
112
- <div className="flex flex-wrap items-start justify-between gap-4 mb-6">
113
- <div className="flex gap-2 flex-wrap">
114
- <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
115
- {q.examTypeName}
116
- </span>
117
- <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
118
- {q.sectionTypeName}
119
- </span>
120
- <span className="px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] text-sm font-semibold">
121
- {formatLabel(q.format)}
122
- </span>
123
- <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
124
- Level {q.difficulty}
125
- </span>
126
- </div>
127
- <div className="flex items-center gap-3">
128
- <StarRating
129
- value={ratingQuery.data?.myRating ?? null}
130
- onChange={(score) => rateMutation.mutate({ questionId: id, score })}
131
- />
132
- {ratingQuery.data?.avgRating && (
133
- <span className="text-sm text-[var(--warm-charcoal)]">
134
- {ratingQuery.data.avgRating}/5
135
- </span>
136
- )}
137
- </div>
138
- </div>
139
-
140
- {/* Passage */}
141
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
142
- <CardContent className="p-6">
143
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
144
- <MaterialIcon name="menu_book" />
145
- Teks Bacaan
146
- </h2>
147
- <div className="text-[var(--clay-black)] leading-relaxed whitespace-pre-wrap">
148
- {q.passageText}
149
- </div>
150
- </CardContent>
151
- </Card>
152
-
153
- {/* Question */}
154
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
155
- <CardContent className="p-6">
156
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
157
- <MaterialIcon name="help_outline" />
158
- Pertanyaan
159
- </h2>
160
- <p className="text-lg text-[var(--clay-black)] font-medium mb-4">{q.questionText}</p>
161
-
162
- {!!q.options && Array.isArray(q.options as unknown[]) && (q.options as unknown[]).length > 0 && (
163
- <div className="space-y-2 mt-4">
164
- {(q.options as Array<{ key: string; text: string }>).map((opt) => (
165
- <div
166
- key={opt.key}
167
- className={`flex items-center p-4 rounded-[var(--radius-lg)] border-2 transition-all ${
168
- opt.key === q.correctAnswer
169
- ? "border-[var(--matcha-600)] bg-[var(--matcha-300)]/20"
170
- : "border-[var(--oat-border)] bg-[var(--oat-light)]"
171
- }`}
172
- >
173
- <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
174
- {opt.key}
175
- </span>
176
- <span className={`${opt.key === q.correctAnswer ? "font-semibold text-[var(--matcha-800)]" : "text-[var(--clay-black)]"}`}>
177
- {opt.text}
178
- </span>
179
- {opt.key === q.correctAnswer && (
180
- <span className="ml-auto text-xs font-bold text-[var(--matcha-800)] bg-[var(--matcha-300)] px-2 py-1 rounded-full">
181
- Benar
182
- </span>
183
- )}
184
- </div>
185
- ))}
186
- </div>
187
- )}
188
-
189
- {!q.options && (
190
- <div className="p-4 rounded-[var(--radius-md)] border-2 border-[var(--matcha-300)] bg-[var(--matcha-300)]/20 mt-4">
191
- <span className="font-semibold text-[var(--matcha-800)]">Jawaban: </span>
192
- <span className="text-[var(--matcha-800)]">{q.correctAnswer}</span>
193
- </div>
194
- )}
195
- </CardContent>
196
- </Card>
197
-
198
- {/* Explanation */}
199
- {q.explanation && (
200
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
201
- <CardContent className="p-6">
202
- <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
203
- <MaterialIcon name="lightbulb" />
204
- Penjelasan
205
- </h2>
206
- <p className="text-[var(--warm-charcoal)] leading-relaxed">{q.explanation}</p>
207
- </CardContent>
208
- </Card>
209
- )}
210
-
211
- {/* Meta */}
212
- <div className="flex flex-wrap items-center justify-between gap-4 text-sm text-[var(--warm-charcoal)]">
213
- <div className="flex gap-4">
214
- <span>Dibuat oleh {q.creatorName ?? "Anonim"}</span>
215
- <span>β€’</span>
216
- <span>{q.usageCount}x digunakan</span>
217
- <span>β€’</span>
218
- <span className="capitalize">{q.source}</span>
219
- </div>
220
- {isOwner && (
221
- <div className="flex gap-2">
222
- <span className={`px-2 py-1 rounded-full text-xs font-semibold ${q.isPublic ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"}`}>
223
- {q.isPublic ? "Publik" : "Privat"}
224
- </span>
225
- </div>
226
- )}
227
- </div>
228
- </div>
229
- );
230
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/bank.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import { useState } from "react";
2
- import { useQuery } 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 { Input } from "@labas/ui/components/input";
@@ -71,13 +71,564 @@ function formatLabel(fmt: string) {
71
  return fmt.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
72
  }
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  function BankComponent() {
 
 
 
 
 
75
  const [search, setSearch] = useState("");
76
  const [examType, setExamType] = useState<string>("");
77
  const [section, setSection] = useState<string>("");
78
  const [format, setFormat] = useState<string>("");
79
  const [difficulty, setDifficulty] = useState<number | undefined>();
80
  const [page, setPage] = useState(0);
 
 
 
 
 
 
 
 
81
  const limit = 12;
82
 
83
  const query = useQuery(
@@ -87,7 +638,9 @@ function BankComponent() {
87
  sectionTypeId: section || undefined,
88
  format: format || undefined,
89
  difficulty,
90
- isPublic: true,
 
 
91
  limit,
92
  offset: page * limit,
93
  }),
@@ -97,6 +650,20 @@ function BankComponent() {
97
  const total = query.data?.total ?? 0;
98
  const totalPages = Math.ceil(total / limit);
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  const clearFilters = () => {
101
  setSearch("");
102
  setExamType("");
@@ -108,6 +675,160 @@ function BankComponent() {
108
 
109
  const hasFilters = search || examType || section || format || difficulty !== undefined;
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  return (
112
  <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)]">
113
  <section className="mb-8">
@@ -115,10 +836,34 @@ function BankComponent() {
115
  Bank Soal
116
  </h1>
117
  <p className="text-lg text-[var(--warm-charcoal)] mt-2">
118
- Jelajahi soal latihan dari komunitas.
119
  </p>
120
  </section>
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  {/* Search & Filters */}
123
  <div className="flex flex-col gap-4 mb-8">
124
  <div className="flex gap-3">
@@ -173,7 +918,7 @@ function BankComponent() {
173
  value={section}
174
  onValueChange={(v: string | null) => { setSection(v ?? ""); setPage(0); }}
175
  >
176
- <SelectTrigger className="w-36">
177
  <SelectValue placeholder="Semua Section" />
178
  </SelectTrigger>
179
  <SelectContent>
@@ -194,7 +939,7 @@ function BankComponent() {
194
  value={format}
195
  onValueChange={(v: string | null) => { setFormat(v ?? ""); setPage(0); }}
196
  >
197
- <SelectTrigger className="w-40">
198
  <SelectValue placeholder="Semua Format" />
199
  </SelectTrigger>
200
  <SelectContent>
@@ -230,6 +975,76 @@ function BankComponent() {
230
  </div>
231
  </div>
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  {/* Results */}
234
  {query.isLoading ? (
235
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -246,18 +1061,58 @@ function BankComponent() {
246
  ) : (
247
  <>
248
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
249
- {questions.map((q) => (
250
- <Link key={q.id} to="/bank/$id" params={{ id: q.id }} className="block">
251
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full flex flex-col">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  <CardContent className="p-5 flex flex-col h-full">
253
  <div className="flex items-start justify-between mb-3">
254
- <div className="flex gap-2 flex-wrap">
255
- <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
256
- {q.examTypeName}
257
- </span>
258
- <span className="px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
259
- {q.sectionTypeName}
260
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  </div>
262
  {q.avgRating && (
263
  <div className="flex items-center gap-1 text-[var(--lemon-700)]">
@@ -267,7 +1122,7 @@ function BankComponent() {
267
  )}
268
  </div>
269
 
270
- <h3 className="font-headline text-base font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
271
  {q.questionText}
272
  </h3>
273
 
@@ -288,10 +1143,42 @@ function BankComponent() {
288
  {q.usageCount}x digunakan
289
  </span>
290
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  </CardContent>
292
  </Card>
293
- </Link>
294
- ))}
295
  </div>
296
 
297
  {/* Pagination */}
@@ -320,6 +1207,70 @@ function BankComponent() {
320
  )}
321
  </>
322
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  </div>
324
  );
325
  }
 
1
+ import { useState, useMemo } from "react";
2
+ import { useQuery, useMutation } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Input } from "@labas/ui/components/input";
 
71
  return fmt.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
72
  }
73
 
74
+ function StarRating({ value }: { value: number | null }) {
75
+ return (
76
+ <div className="flex gap-1">
77
+ {[1, 2, 3, 4, 5].map((star) => (
78
+ <MaterialIcon
79
+ key={star}
80
+ name={(value ?? 0) >= star ? "star" : "star_outline"}
81
+ className={`text-xl ${(value ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
82
+ />
83
+ ))}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ // ── Create Package Modal ────────────────────────────────────
89
+
90
+ function CreatePackageModal({
91
+ selectedCount,
92
+ onClose,
93
+ onCreate,
94
+ isPending,
95
+ examTypeName,
96
+ lowCount,
97
+ }: {
98
+ selectedCount: number;
99
+ onClose: () => void;
100
+ onCreate: (data: { title: string; description: string; isPublic: boolean; examTypeId: string }) => void;
101
+ isPending: boolean;
102
+ examTypeName: string;
103
+ lowCount: boolean;
104
+ }) {
105
+ const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
106
+ const [title, setTitle] = useState(`${examTypeName} Bundle β€” ${selectedCount} Soal`);
107
+ const [description, setDescription] = useState("");
108
+ const [isPublic, setIsPublic] = useState(false);
109
+
110
+ return (
111
+ <div
112
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
113
+ onClick={(e) => {
114
+ if (e.target === e.currentTarget) onClose();
115
+ }}
116
+ >
117
+ <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
118
+ <div className="flex items-center justify-between mb-6">
119
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
120
+ Buat Paket Soal
121
+ </h2>
122
+ <button
123
+ onClick={onClose}
124
+ className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
125
+ >
126
+ <MaterialIcon name="close" className="text-[var(--clay-black)]" />
127
+ </button>
128
+ </div>
129
+
130
+ <div className="flex items-center gap-2 mb-4">
131
+ <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
132
+ {examTypeName}
133
+ </span>
134
+ <span className="text-sm text-[var(--warm-charcoal)]">
135
+ {selectedCount} soal
136
+ </span>
137
+ </div>
138
+
139
+ {lowCount && (
140
+ <div className="mb-4 p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
141
+ <MaterialIcon name="info" className="text-sm mt-0.5 shrink-0" />
142
+ <span>
143
+ Soal yang tersedia sedikit ({selectedCount} soal). Paket tetap bisa dibuat, tapi disarankan untuk menambah soal.
144
+ </span>
145
+ </div>
146
+ )}
147
+
148
+ <div className="space-y-4">
149
+ <div>
150
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
151
+ Judul Paket
152
+ </label>
153
+ <Input
154
+ value={title}
155
+ onChange={(e) => setTitle(e.target.value)}
156
+ placeholder="e.g. IELTS Reading Bundle"
157
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
158
+ />
159
+ </div>
160
+
161
+ <div>
162
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
163
+ Deskripsi
164
+ </label>
165
+ <Input
166
+ value={description}
167
+ onChange={(e) => setDescription(e.target.value)}
168
+ placeholder="Deskripsi singkat..."
169
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
170
+ />
171
+ </div>
172
+
173
+ <label className="flex items-center gap-2 cursor-pointer">
174
+ <input
175
+ type="checkbox"
176
+ checked={isPublic}
177
+ onChange={(e) => setIsPublic(e.target.checked)}
178
+ className="w-4 h-4 rounded border-[var(--oat-border)]"
179
+ />
180
+ <span className="text-sm text-[var(--warm-charcoal)]">
181
+ Publikasikan ke Bank Soal
182
+ </span>
183
+ </label>
184
+ </div>
185
+
186
+ <div className="mt-6 flex gap-3">
187
+ <Button
188
+ variant="outline"
189
+ onClick={onClose}
190
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
191
+ >
192
+ Batal
193
+ </Button>
194
+ <Button
195
+ onClick={() => onCreate({ title, description, isPublic, examTypeId: "" })}
196
+ disabled={!title || isPending}
197
+ className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
198
+ >
199
+ {isPending ? "Membuat..." : "Buat Paket"}
200
+ </Button>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ // ── Auto Bundle Modal ───────────────────────────────────────
208
+
209
+ function AutoBundleModal({
210
+ availableCount,
211
+ examTypeId,
212
+ examTypeName,
213
+ sectionTypeId,
214
+ sectionTypeName,
215
+ onClose,
216
+ onCreate,
217
+ isPending,
218
+ }: {
219
+ availableCount: number;
220
+ examTypeId: string;
221
+ examTypeName: string;
222
+ sectionTypeId: string;
223
+ sectionTypeName: string;
224
+ onClose: () => void;
225
+ onCreate: (data: {
226
+ title: string;
227
+ description: string;
228
+ isPublic: boolean;
229
+ examTypeId: string;
230
+ sectionTypeId: string;
231
+ count: number;
232
+ sortOrder: "random" | "difficulty";
233
+ }) => void;
234
+ isPending: boolean;
235
+ }) {
236
+ const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
237
+ const [count, setCount] = useState(Math.min(availableCount, 10));
238
+ const [title, setTitle] = useState(`${examTypeName} ${sectionTypeName} Bundle β€” ${dateStr}`);
239
+ const [description, setDescription] = useState("");
240
+ const [isPublic, setIsPublic] = useState(false);
241
+ const [sortOrder, setSortOrder] = useState<"random" | "difficulty">("random");
242
+
243
+ return (
244
+ <div
245
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
246
+ onClick={(e) => {
247
+ if (e.target === e.currentTarget) onClose();
248
+ }}
249
+ >
250
+ <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
251
+ <div className="flex items-center justify-between mb-6">
252
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
253
+ Auto Bundle
254
+ </h2>
255
+ <button
256
+ onClick={onClose}
257
+ className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
258
+ >
259
+ <MaterialIcon name="close" className="text-[var(--clay-black)]" />
260
+ </button>
261
+ </div>
262
+
263
+ <div className="flex items-center gap-2 mb-4">
264
+ <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
265
+ {examTypeName}
266
+ </span>
267
+ {sectionTypeName && (
268
+ <span className="px-3 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
269
+ {sectionTypeName}
270
+ </span>
271
+ )}
272
+ <span className="text-sm text-[var(--warm-charcoal)]">
273
+ {availableCount} soal tersedia
274
+ </span>
275
+ </div>
276
+
277
+ {availableCount < 5 && (
278
+ <div className="mb-4 p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
279
+ <MaterialIcon name="info" className="text-sm mt-0.5 shrink-0" />
280
+ <span>
281
+ Soal yang tersedia sedikit ({availableCount} soal). Paket tetap bisa dibuat, tapi disarankan untuk menambah soal.
282
+ </span>
283
+ </div>
284
+ )}
285
+
286
+ <div className="space-y-4">
287
+ <div>
288
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
289
+ Judul Paket
290
+ </label>
291
+ <Input
292
+ value={title}
293
+ onChange={(e) => setTitle(e.target.value)}
294
+ placeholder="e.g. IELTS Reading Bundle"
295
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
296
+ />
297
+ </div>
298
+
299
+ <div>
300
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
301
+ Deskripsi
302
+ </label>
303
+ <Input
304
+ value={description}
305
+ onChange={(e) => setDescription(e.target.value)}
306
+ placeholder="Deskripsi singkat..."
307
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
308
+ />
309
+ </div>
310
+
311
+ <div>
312
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
313
+ Jumlah Soal: {count}
314
+ </label>
315
+ <input
316
+ type="range"
317
+ min={1}
318
+ max={availableCount}
319
+ value={count}
320
+ onChange={(e) => setCount(Number(e.target.value))}
321
+ className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
322
+ />
323
+ </div>
324
+
325
+ <div>
326
+ <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">
327
+ Urutan Soal
328
+ </label>
329
+ <div className="flex gap-2">
330
+ <button
331
+ onClick={() => setSortOrder("random")}
332
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
333
+ sortOrder === "random"
334
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
335
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
336
+ }`}
337
+ >
338
+ <MaterialIcon name="shuffle" className="text-sm mr-1" />
339
+ Acak
340
+ </button>
341
+ <button
342
+ onClick={() => setSortOrder("difficulty")}
343
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
344
+ sortOrder === "difficulty"
345
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
346
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
347
+ }`}
348
+ >
349
+ <MaterialIcon name="trending_up" className="text-sm mr-1" />
350
+ Difficulty
351
+ </button>
352
+ </div>
353
+ </div>
354
+
355
+ <label className="flex items-center gap-2 cursor-pointer">
356
+ <input
357
+ type="checkbox"
358
+ checked={isPublic}
359
+ onChange={(e) => setIsPublic(e.target.checked)}
360
+ className="w-4 h-4 rounded border-[var(--oat-border)]"
361
+ />
362
+ <span className="text-sm text-[var(--warm-charcoal)]">
363
+ Publikasikan ke Bank Soal
364
+ </span>
365
+ </label>
366
+ </div>
367
+
368
+ <div className="mt-6 flex gap-3">
369
+ <Button
370
+ variant="outline"
371
+ onClick={onClose}
372
+ className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
373
+ >
374
+ Batal
375
+ </Button>
376
+ <Button
377
+ onClick={() => onCreate({
378
+ title,
379
+ description,
380
+ isPublic,
381
+ examTypeId,
382
+ sectionTypeId: sectionTypeId || "READING",
383
+ count,
384
+ sortOrder,
385
+ })}
386
+ disabled={!title || isPending || count < 1}
387
+ className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
388
+ >
389
+ {isPending ? "Membuat..." : "Buat Paket"}
390
+ </Button>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ );
395
+ }
396
+
397
+ // ── Question Detail Modal ───────────────────────────────────
398
+
399
+ function QuestionDetailModal({
400
+ question,
401
+ onClose,
402
+ isSelected,
403
+ onToggleSelect,
404
+ isSelectable,
405
+ }: {
406
+ question: any;
407
+ onClose: () => void;
408
+ isSelected: boolean;
409
+ onToggleSelect: () => void;
410
+ isSelectable: boolean;
411
+ }) {
412
+ const { data: session } = authClient.useSession();
413
+ const isOwner = question.creatorUserId === session?.user.id;
414
+
415
+ const ratingQuery = useQuery(
416
+ trpc.rating.getQuestionRating.queryOptions({ questionId: question.id }),
417
+ );
418
+
419
+ const rateMutation = useMutation({
420
+ ...trpc.rating.rateQuestion.mutationOptions(),
421
+ onSuccess: () => ratingQuery.refetch(),
422
+ });
423
+
424
+ return (
425
+ <div
426
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
427
+ onClick={(e) => {
428
+ if (e.target === e.currentTarget) onClose();
429
+ }}
430
+ >
431
+ <div className="bg-[var(--warm-cream)] w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
432
+ {/* Header */}
433
+ <div className="flex items-start justify-between mb-6">
434
+ <div className="flex gap-2 flex-wrap">
435
+ <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
436
+ {question.examTypeName}
437
+ </span>
438
+ <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
439
+ {question.sectionTypeName}
440
+ </span>
441
+ <span className="px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] text-sm font-semibold">
442
+ {formatLabel(question.format)}
443
+ </span>
444
+ <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
445
+ Level {question.difficulty}
446
+ </span>
447
+ </div>
448
+ <button
449
+ onClick={onClose}
450
+ className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
451
+ >
452
+ <MaterialIcon name="close" className="text-[var(--clay-black)]" />
453
+ </button>
454
+ </div>
455
+
456
+ {/* Passage */}
457
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
458
+ <CardContent className="p-6">
459
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
460
+ <MaterialIcon name="menu_book" />
461
+ Teks Bacaan
462
+ </h2>
463
+ <div className="text-[var(--clay-black)] leading-relaxed whitespace-pre-wrap text-sm">
464
+ {question.passageText}
465
+ </div>
466
+ </CardContent>
467
+ </Card>
468
+
469
+ {/* Question */}
470
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
471
+ <CardContent className="p-6">
472
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4 flex items-center gap-2">
473
+ <MaterialIcon name="help_outline" />
474
+ Pertanyaan
475
+ </h2>
476
+ <p className="text-lg text-[var(--clay-black)] font-medium mb-4">
477
+ {question.questionText}
478
+ </p>
479
+
480
+ {!!question.options &&
481
+ Array.isArray(question.options as unknown[]) &&
482
+ (question.options as unknown[]).length > 0 && (
483
+ <div className="space-y-2 mt-4">
484
+ {(question.options as Array<{ key: string; text: string }>).map(
485
+ (opt) => (
486
+ <div
487
+ key={opt.key}
488
+ className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
489
+ >
490
+ <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
491
+ {opt.key}
492
+ </span>
493
+ <span className="text-[var(--clay-black)]">{opt.text}</span>
494
+ </div>
495
+ ),
496
+ )}
497
+ </div>
498
+ )}
499
+
500
+ {!question.options && (
501
+ <div className="p-4 rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)] mt-4 text-sm text-[var(--warm-charcoal)]">
502
+ <span className="font-semibold text-[var(--clay-black)]">
503
+ Jenis soal:{" "}
504
+ </span>
505
+ {formatLabel(question.format)}
506
+ </div>
507
+ )}
508
+ </CardContent>
509
+ </Card>
510
+
511
+ {/* Lock card */}
512
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
513
+ <CardContent className="p-6">
514
+ <h2 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 flex items-center gap-2">
515
+ <MaterialIcon name="lock" />
516
+ Jawaban & Penjelasan
517
+ </h2>
518
+ <p className="text-sm text-[var(--warm-charcoal)]">
519
+ Jawaban dan penjelasan akan tersedia setelah kamu mencoba mengerjakan
520
+ soal ini dalam paket latihan.
521
+ </p>
522
+ </CardContent>
523
+ </Card>
524
+
525
+ {/* Rating */}
526
+ <div className="flex items-center gap-4 mb-6">
527
+ <div className="flex gap-1">
528
+ {[1, 2, 3, 4, 5].map((star) => (
529
+ <button
530
+ key={star}
531
+ onClick={() => rateMutation.mutate({ questionId: question.id, score: star })}
532
+ className="transition-transform hover:scale-110"
533
+ >
534
+ <MaterialIcon
535
+ name={(ratingQuery.data?.myRating ?? 0) >= star ? "star" : "star_outline"}
536
+ className={`text-xl ${(ratingQuery.data?.myRating ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
537
+ />
538
+ </button>
539
+ ))}
540
+ </div>
541
+ {ratingQuery.data?.avgRating && (
542
+ <span className="text-sm text-[var(--warm-charcoal)]">
543
+ {ratingQuery.data.avgRating}/5 ({question.usageCount}x digunakan)
544
+ </span>
545
+ )}
546
+ {!ratingQuery.data?.avgRating && (
547
+ <span className="text-sm text-[var(--warm-charcoal)]">
548
+ {question.usageCount}x digunakan
549
+ </span>
550
+ )}
551
+ </div>
552
+
553
+ {/* Meta & Owner Actions */}
554
+ <div className="flex flex-wrap items-center justify-between gap-4 text-sm text-[var(--warm-charcoal)] border-t border-[var(--oat-border)] pt-4">
555
+ <div className="flex gap-4">
556
+ <span>Dibuat oleh {question.creatorName ?? "Anonim"}</span>
557
+ <span>β€’</span>
558
+ <span className="capitalize">{question.source}</span>
559
+ </div>
560
+ {isOwner && (
561
+ <div className="flex gap-2 items-center">
562
+ <span
563
+ className={`px-3 py-1.5 rounded-full text-xs font-semibold ${
564
+ question.isPublic
565
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
566
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
567
+ }`}
568
+ >
569
+ {question.isPublic ? "Publik" : "Privat"}
570
+ </span>
571
+ </div>
572
+ )}
573
+ </div>
574
+
575
+ {/* Footer Actions */}
576
+ <div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3">
577
+ <Button
578
+ variant="outline"
579
+ onClick={onToggleSelect}
580
+ disabled={!isSelectable}
581
+ className={`rounded-[var(--radius-lg)] border-2 clay-hover ${
582
+ !isSelectable
583
+ ? "opacity-40 cursor-not-allowed border-[var(--oat-border)] text-[var(--warm-silver)]"
584
+ : isSelected
585
+ ? "border-[var(--pomegranate-400)] text-[var(--pomegranate-600)] bg-[var(--pomegranate-50)]"
586
+ : "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
587
+ }`}
588
+ >
589
+ <MaterialIcon name={isSelected ? "remove" : "add"} className="mr-2" />
590
+ {!isSelectable
591
+ ? "Jenis ujian berbeda"
592
+ : isSelected
593
+ ? "Hapus dari Pilihan"
594
+ : "Tambah ke Paket"}
595
+ </Button>
596
+ <Button
597
+ onClick={onClose}
598
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
599
+ >
600
+ Tutup
601
+ </Button>
602
+ </div>
603
+ </div>
604
+ </div>
605
+ );
606
+ }
607
+
608
+ // ── Bank Component ──────────────────────────────────────────
609
+
610
+ type Tab = "mine" | "public";
611
+
612
  function BankComponent() {
613
+ const { data: session } = authClient.useSession();
614
+ const userId = session?.user.id;
615
+ const navigate = useNavigate();
616
+
617
+ const [tab, setTab] = useState<Tab>("mine");
618
  const [search, setSearch] = useState("");
619
  const [examType, setExamType] = useState<string>("");
620
  const [section, setSection] = useState<string>("");
621
  const [format, setFormat] = useState<string>("");
622
  const [difficulty, setDifficulty] = useState<number | undefined>();
623
  const [page, setPage] = useState(0);
624
+ const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
625
+
626
+ // Multi-select state
627
+ const [isSelectMode, setIsSelectMode] = useState(false);
628
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
629
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
630
+ const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
631
+
632
  const limit = 12;
633
 
634
  const query = useQuery(
 
638
  sectionTypeId: section || undefined,
639
  format: format || undefined,
640
  difficulty,
641
+ ...(tab === "mine" && userId
642
+ ? { creatorUserId: userId }
643
+ : { isPublic: true }),
644
  limit,
645
  offset: page * limit,
646
  }),
 
650
  const total = query.data?.total ?? 0;
651
  const totalPages = Math.ceil(total / limit);
652
 
653
+ const togglePublic = useMutation({
654
+ ...trpc.question.togglePublic.mutationOptions(),
655
+ onSuccess: () => query.refetch(),
656
+ });
657
+
658
+ const deleteQuestion = useMutation({
659
+ ...trpc.question.delete.mutationOptions(),
660
+ onSuccess: () => query.refetch(),
661
+ });
662
+
663
+ const createPackage = useMutation(trpc.package.create.mutationOptions());
664
+ const addSection = useMutation(trpc.package.addSection.mutationOptions());
665
+ const addQuestion = useMutation(trpc.package.addQuestion.mutationOptions());
666
+
667
  const clearFilters = () => {
668
  setSearch("");
669
  setExamType("");
 
675
 
676
  const hasFilters = search || examType || section || format || difficulty !== undefined;
677
 
678
+ // Strict exam type guard: derive the locked exam type from first selection
679
+ const lockedExamType = useMemo(() => {
680
+ if (selectedIds.size === 0) return null;
681
+ const firstId = Array.from(selectedIds)[0];
682
+ const firstQ = questions.find((q) => q.id === firstId);
683
+ return firstQ?.examTypeId ?? null;
684
+ }, [selectedIds, questions]);
685
+
686
+ const toggleSelection = (id: string) => {
687
+ const q = questions.find((item) => item.id === id);
688
+ if (!q) return;
689
+
690
+ // Strict guard: cannot select different exam type
691
+ if (lockedExamType && q.examTypeId !== lockedExamType) return;
692
+
693
+ setSelectedIds((prev) => {
694
+ const next = new Set(prev);
695
+ if (next.has(id)) {
696
+ next.delete(id);
697
+ } else {
698
+ next.add(id);
699
+ }
700
+ return next;
701
+ });
702
+ };
703
+
704
+ const selectAll = () => {
705
+ // Only select questions that match the locked exam type
706
+ if (lockedExamType) {
707
+ setSelectedIds(new Set(questions.filter((q) => q.examTypeId === lockedExamType).map((q) => q.id)));
708
+ } else {
709
+ setSelectedIds(new Set(questions.map((q) => q.id)));
710
+ }
711
+ };
712
+
713
+ const clearSelection = () => {
714
+ setSelectedIds(new Set());
715
+ };
716
+
717
+ const exitSelectMode = () => {
718
+ setIsSelectMode(false);
719
+ setSelectedIds(new Set());
720
+ };
721
+
722
+ const handleCreatePackage = async (data: {
723
+ title: string;
724
+ description: string;
725
+ isPublic: boolean;
726
+ examTypeId: string;
727
+ }) => {
728
+ if (selectedIds.size === 0) return;
729
+
730
+ // Derive examTypeId from first selected question
731
+ const firstId = Array.from(selectedIds)[0];
732
+ const firstQ = questions.find((q) => q.id === firstId);
733
+ const examTypeId = firstQ?.examTypeId ?? data.examTypeId;
734
+
735
+ try {
736
+ const pkg = await createPackage.mutateAsync({
737
+ title: data.title,
738
+ description: data.description,
739
+ examTypeId,
740
+ isPublic: data.isPublic,
741
+ estimatedDurationMin: selectedIds.size * 2,
742
+ });
743
+
744
+ const sec = await addSection.mutateAsync({
745
+ packageId: pkg.id,
746
+ sectionTypeId: firstQ?.sectionTypeId ?? "READING",
747
+ title: `${firstQ?.sectionTypeName ?? "Reading"} Section`,
748
+ orderIndex: 0,
749
+ });
750
+
751
+ const ids = Array.from(selectedIds);
752
+ for (let i = 0; i < ids.length; i++) {
753
+ await addQuestion.mutateAsync({
754
+ sectionId: sec.id,
755
+ questionId: ids[i],
756
+ orderIndex: i,
757
+ });
758
+ }
759
+
760
+ setIsCreateModalOpen(false);
761
+ setIsSelectMode(false);
762
+ setSelectedIds(new Set());
763
+ navigate({ to: "/package/$id", params: { id: pkg.id } });
764
+ } catch (err: any) {
765
+ alert("Gagal membuat paket: " + err.message);
766
+ }
767
+ };
768
+
769
+ const handleAutoBundle = async (data: {
770
+ title: string;
771
+ description: string;
772
+ isPublic: boolean;
773
+ examTypeId: string;
774
+ sectionTypeId: string;
775
+ count: number;
776
+ sortOrder: "random" | "difficulty";
777
+ }) => {
778
+ // Fetch all questions for this exam type (not just current page)
779
+ const allQuestions = await query.refetch();
780
+ const available = (allQuestions.data?.questions ?? []).filter(
781
+ (q) => q.examTypeId === data.examTypeId,
782
+ );
783
+
784
+ let picked = available;
785
+ if (data.sortOrder === "random") {
786
+ picked = [...available].sort(() => Math.random() - 0.5);
787
+ } else {
788
+ picked = [...available].sort((a, b) => a.difficulty - b.difficulty);
789
+ }
790
+ picked = picked.slice(0, data.count);
791
+
792
+ if (picked.length === 0) {
793
+ alert("Tidak ada soal tersedia untuk ujian ini.");
794
+ return;
795
+ }
796
+
797
+ try {
798
+ const pkg = await createPackage.mutateAsync({
799
+ title: data.title,
800
+ description: data.description,
801
+ examTypeId: data.examTypeId,
802
+ isPublic: data.isPublic,
803
+ estimatedDurationMin: picked.length * 2,
804
+ });
805
+
806
+ const sec = await addSection.mutateAsync({
807
+ packageId: pkg.id,
808
+ sectionTypeId: data.sectionTypeId,
809
+ title: `${data.sectionTypeId} Section`,
810
+ orderIndex: 0,
811
+ });
812
+
813
+ for (let i = 0; i < picked.length; i++) {
814
+ await addQuestion.mutateAsync({
815
+ sectionId: sec.id,
816
+ questionId: picked[i].id,
817
+ orderIndex: i,
818
+ });
819
+ }
820
+
821
+ setIsAutoBundleOpen(false);
822
+ navigate({ to: "/package/$id", params: { id: pkg.id } });
823
+ } catch (err: any) {
824
+ alert("Gagal membuat paket: " + err.message);
825
+ }
826
+ };
827
+
828
+ // Auto-bundle: count available questions for current filters
829
+ const autoBundleExamType = examType || null;
830
+ const autoBundleSectionType = section || null;
831
+
832
  return (
833
  <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)]">
834
  <section className="mb-8">
 
836
  Bank Soal
837
  </h1>
838
  <p className="text-lg text-[var(--warm-charcoal)] mt-2">
839
+ Kelola dan jelajahi soal latihan. Pilih soal untuk dibuatkan paket.
840
  </p>
841
  </section>
842
 
843
+ {/* Tabs */}
844
+ <div className="flex gap-2 mb-6">
845
+ <button
846
+ onClick={() => { setTab("mine"); setPage(0); }}
847
+ className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
848
+ tab === "mine"
849
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
850
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
851
+ }`}
852
+ >
853
+ Soal Saya
854
+ </button>
855
+ <button
856
+ onClick={() => { setTab("public"); setPage(0); }}
857
+ className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
858
+ tab === "public"
859
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
860
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
861
+ }`}
862
+ >
863
+ Publik
864
+ </button>
865
+ </div>
866
+
867
  {/* Search & Filters */}
868
  <div className="flex flex-col gap-4 mb-8">
869
  <div className="flex gap-3">
 
918
  value={section}
919
  onValueChange={(v: string | null) => { setSection(v ?? ""); setPage(0); }}
920
  >
921
+ <SelectTrigger className="w-38">
922
  <SelectValue placeholder="Semua Section" />
923
  </SelectTrigger>
924
  <SelectContent>
 
939
  value={format}
940
  onValueChange={(v: string | null) => { setFormat(v ?? ""); setPage(0); }}
941
  >
942
+ <SelectTrigger className="w-52">
943
  <SelectValue placeholder="Semua Format" />
944
  </SelectTrigger>
945
  <SelectContent>
 
975
  </div>
976
  </div>
977
 
978
+ {/* Select Mode Toolbar */}
979
+ <div className="flex items-center justify-between mb-4">
980
+ <div className="flex items-center gap-3">
981
+ {!isSelectMode ? (
982
+ <>
983
+ <Button
984
+ variant="outline"
985
+ onClick={() => setIsSelectMode(true)}
986
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
987
+ >
988
+ <MaterialIcon name="checklist" className="mr-2" />
989
+ Mode Pilih
990
+ </Button>
991
+ <Button
992
+ variant="outline"
993
+ onClick={() => setIsAutoBundleOpen(true)}
994
+ disabled={!autoBundleExamType}
995
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
996
+ >
997
+ <MaterialIcon name="auto_fix_high" className="mr-2" />
998
+ Auto Bundle
999
+ </Button>
1000
+ {!autoBundleExamType && (
1001
+ <span className="text-xs text-[var(--warm-silver)]">
1002
+ Pilih jenis ujian dulu untuk Auto Bundle
1003
+ </span>
1004
+ )}
1005
+ </>
1006
+ ) : (
1007
+ <div className="flex items-center gap-2">
1008
+ <Button
1009
+ variant="outline"
1010
+ onClick={exitSelectMode}
1011
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
1012
+ >
1013
+ <MaterialIcon name="close" className="mr-2" />
1014
+ Selesai
1015
+ </Button>
1016
+ <Button
1017
+ variant="outline"
1018
+ onClick={selectAll}
1019
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
1020
+ >
1021
+ Pilih Semua
1022
+ </Button>
1023
+ <Button
1024
+ variant="outline"
1025
+ onClick={clearSelection}
1026
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
1027
+ >
1028
+ Batal Pilih
1029
+ </Button>
1030
+ </div>
1031
+ )}
1032
+ </div>
1033
+
1034
+ {isSelectMode && (
1035
+ <div className="flex items-center gap-3">
1036
+ {lockedExamType && (
1037
+ <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
1038
+ {EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}
1039
+ </span>
1040
+ )}
1041
+ <span className="text-sm font-semibold text-[var(--clay-black)]">
1042
+ {selectedIds.size} soal dipilih
1043
+ </span>
1044
+ </div>
1045
+ )}
1046
+ </div>
1047
+
1048
  {/* Results */}
1049
  {query.isLoading ? (
1050
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
 
1061
  ) : (
1062
  <>
1063
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
1064
+ {questions.map((q) => {
1065
+ const isOwner = q.creatorUserId === userId;
1066
+ const isSelected = selectedIds.has(q.id);
1067
+ const isMismatched = isSelectMode && lockedExamType && q.examTypeId !== lockedExamType;
1068
+ return (
1069
+ <Card
1070
+ key={q.id}
1071
+ onClick={() => {
1072
+ if (isSelectMode) {
1073
+ toggleSelection(q.id);
1074
+ } else {
1075
+ setSelectedQuestion(q);
1076
+ }
1077
+ }}
1078
+ className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col cursor-pointer transition-all ${
1079
+ isSelected
1080
+ ? "border-[var(--clay-black)] bg-[var(--matcha-100)]"
1081
+ : isMismatched
1082
+ ? "border-[var(--oat-border)] opacity-30 pointer-events-none"
1083
+ : "border-[var(--oat-border)]"
1084
+ }`}
1085
+ >
1086
  <CardContent className="p-5 flex flex-col h-full">
1087
  <div className="flex items-start justify-between mb-3">
1088
+ <div className="flex items-center gap-2 flex-wrap">
1089
+ {isSelectMode && (
1090
+ <div
1091
+ className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${
1092
+ isSelected
1093
+ ? "bg-[var(--clay-black)] border-[var(--clay-black)]"
1094
+ : isMismatched
1095
+ ? "border-[var(--warm-silver)]"
1096
+ : "border-[var(--oat-border)]"
1097
+ }`}
1098
+ onClick={(e) => {
1099
+ e.stopPropagation();
1100
+ toggleSelection(q.id);
1101
+ }}
1102
+ >
1103
+ {isSelected && (
1104
+ <MaterialIcon name="check" className="text-xs text-[var(--pure-white)]" />
1105
+ )}
1106
+ </div>
1107
+ )}
1108
+ <div className="flex gap-2 flex-wrap">
1109
+ <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
1110
+ {q.examTypeName}
1111
+ </span>
1112
+ <span className="px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
1113
+ {q.sectionTypeName}
1114
+ </span>
1115
+ </div>
1116
  </div>
1117
  {q.avgRating && (
1118
  <div className="flex items-center gap-1 text-[var(--lemon-700)]">
 
1122
  )}
1123
  </div>
1124
 
1125
+ <h3 className="font-headline text-base font-bold text-[var(--clay-black)] line-clamp-2 mb-2">
1126
  {q.questionText}
1127
  </h3>
1128
 
 
1143
  {q.usageCount}x digunakan
1144
  </span>
1145
  </div>
1146
+
1147
+ {isOwner && (
1148
+ <div
1149
+ className="flex items-center justify-between mt-3 pt-3 border-t border-[var(--oat-border)]"
1150
+ onClick={(e) => e.stopPropagation()}
1151
+ >
1152
+ <button
1153
+ onClick={(e) => {
1154
+ e.stopPropagation();
1155
+ togglePublic.mutate({ id: q.id });
1156
+ }}
1157
+ className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors ${
1158
+ q.isPublic
1159
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
1160
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
1161
+ }`}
1162
+ >
1163
+ {q.isPublic ? "Publik" : "Privat"}
1164
+ </button>
1165
+ <button
1166
+ onClick={(e) => {
1167
+ e.stopPropagation();
1168
+ if (confirm("Yakin mau hapus soal ini?")) {
1169
+ deleteQuestion.mutate({ id: q.id });
1170
+ }
1171
+ }}
1172
+ className="text-xs text-[var(--pomegranate-400)] hover:bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full transition-colors"
1173
+ >
1174
+ <MaterialIcon name="delete" className="text-sm" />
1175
+ </button>
1176
+ </div>
1177
+ )}
1178
  </CardContent>
1179
  </Card>
1180
+ );
1181
+ })}
1182
  </div>
1183
 
1184
  {/* Pagination */}
 
1207
  )}
1208
  </>
1209
  )}
1210
+
1211
+ {/* Sticky Bottom Bar for Selection */}
1212
+ {isSelectMode && selectedIds.size > 0 && (
1213
+ <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
1214
+ <div className="flex items-center gap-3 bg-[var(--clay-black)] text-[var(--pure-white)] px-6 py-3 rounded-[var(--radius-xl)] clay-shadow">
1215
+ <span className="text-sm font-semibold">
1216
+ {selectedIds.size} soal dipilih
1217
+ </span>
1218
+ <Button
1219
+ onClick={() => setIsCreateModalOpen(true)}
1220
+ className="bg-[var(--matcha-500)] text-[var(--pure-white)] hover:bg-[var(--matcha-700)] rounded-[var(--radius-lg)] text-sm px-4 py-2 h-auto"
1221
+ >
1222
+ <MaterialIcon name="folder" className="mr-1 text-sm" />
1223
+ Buat Paket
1224
+ </Button>
1225
+ <button
1226
+ onClick={clearSelection}
1227
+ className="text-xs text-[var(--warm-silver)] hover:text-[var(--pure-white)] transition-colors"
1228
+ >
1229
+ Batal
1230
+ </button>
1231
+ </div>
1232
+ </div>
1233
+ )}
1234
+
1235
+ {/* Detail Modal */}
1236
+ {selectedQuestion && (
1237
+ <QuestionDetailModal
1238
+ question={selectedQuestion}
1239
+ onClose={() => setSelectedQuestion(null)}
1240
+ isSelected={selectedIds.has(selectedQuestion.id)}
1241
+ onToggleSelect={() => {
1242
+ toggleSelection(selectedQuestion.id);
1243
+ if (!isSelectMode) setIsSelectMode(true);
1244
+ }}
1245
+ isSelectable={!lockedExamType || selectedQuestion.examTypeId === lockedExamType}
1246
+ />
1247
+ )}
1248
+
1249
+ {/* Create Package Modal */}
1250
+ {isCreateModalOpen && (
1251
+ <CreatePackageModal
1252
+ selectedCount={selectedIds.size}
1253
+ onClose={() => setIsCreateModalOpen(false)}
1254
+ onCreate={handleCreatePackage}
1255
+ isPending={createPackage.isPending || addSection.isPending}
1256
+ examTypeName={EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? "Unknown"}
1257
+ lowCount={selectedIds.size < 5}
1258
+ />
1259
+ )}
1260
+
1261
+ {/* Auto Bundle Modal */}
1262
+ {isAutoBundleOpen && autoBundleExamType && (
1263
+ <AutoBundleModal
1264
+ availableCount={total}
1265
+ examTypeId={autoBundleExamType}
1266
+ examTypeName={EXAM_TYPES.find((t) => t.id === autoBundleExamType)?.name ?? autoBundleExamType}
1267
+ sectionTypeId={autoBundleSectionType ?? "READING"}
1268
+ sectionTypeName={SECTIONS.find((s) => s.id === autoBundleSectionType)?.name ?? "Reading"}
1269
+ onClose={() => setIsAutoBundleOpen(false)}
1270
+ onCreate={handleAutoBundle}
1271
+ isPending={createPackage.isPending || addSection.isPending}
1272
+ />
1273
+ )}
1274
  </div>
1275
  );
1276
  }
apps/web/src/routes/{builder.tsx β†’ builder.combo.tsx} RENAMED
@@ -1,6 +1,6 @@
1
  import { useState } from "react";
2
  import { useQuery, 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 { Input } from "@labas/ui/components/input";
@@ -15,8 +15,8 @@ import {
15
  SelectValue,
16
  } from "@labas/ui/components/select";
17
 
18
- export const Route = createFileRoute("/builder")({
19
- component: BuilderComponent,
20
  beforeLoad: async () => {
21
  const session = await authClient.getSession();
22
  if (!session.data) {
@@ -26,156 +26,126 @@ export const Route = createFileRoute("/builder")({
26
  },
27
  });
28
 
29
- const EXAM_TYPES = [
30
- { id: "IELTS", name: "IELTS" },
31
- { id: "TOEFL", name: "TOEFL" },
32
- { id: "JLPT", name: "JLPT" },
33
- { id: "HSK", name: "HSK" },
34
- { id: "GOETHE", name: "German" },
35
- ];
36
-
37
  function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
38
  return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
39
  }
40
 
41
- function formatLabel(fmt: string) {
42
- return fmt.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
 
 
 
 
 
43
  }
44
 
45
- function BuilderComponent() {
46
  const [step, setStep] = useState<"select" | "review">("select");
47
  const [search, setSearch] = useState("");
48
  const [examTypeFilter, setExamTypeFilter] = useState("");
49
- const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
50
 
51
  // Package form
52
  const [title, setTitle] = useState("");
53
  const [description, setDescription] = useState("");
54
- const [examType, setExamType] = useState("IELTS");
55
  const [isPublic, setIsPublic] = useState(false);
56
 
57
- const questionsQuery = useQuery(
58
- trpc.question.list.queryOptions({
59
- search: search || undefined,
60
  examTypeId: examTypeFilter || undefined,
61
- isPublic: true,
62
  limit: 50,
63
  offset: 0,
64
  }),
65
  );
66
 
67
- const createPackage = useMutation({
68
- ...trpc.package.create.mutationOptions(),
69
  });
70
 
71
- const addSection = useMutation({
72
- ...trpc.package.addSection.mutationOptions(),
73
- });
74
-
75
- const addQuestion = useMutation({
76
- ...trpc.package.addQuestion.mutationOptions(),
77
- });
78
-
79
- const toggleQuestion = (id: string) => {
80
- setSelectedQuestions((prev) =>
81
- prev.includes(id) ? prev.filter((q) => q !== id) : [...prev, id],
82
- );
83
  };
84
 
85
- const handleCreatePackage = async () => {
86
- if (!title || selectedQuestions.length === 0) return;
87
 
88
  try {
89
- const pkg = await createPackage.mutateAsync({
90
  title,
91
  description,
92
- examTypeId: examType,
93
  isPublic,
94
- estimatedDurationMin: selectedQuestions.length * 2,
95
- });
96
-
97
- const sec = await addSection.mutateAsync({
98
- packageId: pkg.id,
99
- sectionTypeId: "READING",
100
- title: "Reading Section",
101
- orderIndex: 0,
102
- });
103
-
104
- for (let i = 0; i < selectedQuestions.length; i++) {
105
- await addQuestion.mutateAsync({
106
- sectionId: sec.id,
107
- questionId: selectedQuestions[i],
108
  orderIndex: i,
109
- });
110
- }
111
 
112
- alert(`Paket "${title}" berhasil dibuat!`);
113
- setSelectedQuestions([]);
114
  setTitle("");
115
  setDescription("");
116
  setStep("select");
117
  } catch (err: any) {
118
- alert("Gagal membuat paket: " + err.message);
119
  }
120
  };
121
 
122
- const questions = questionsQuery.data?.questions ?? [];
123
- const selectedCount = selectedQuestions.length;
 
 
 
 
 
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
  <section className="mb-8">
128
  <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
129
- Test Builder
130
  </h1>
131
  <p className="text-lg text-[var(--warm-charcoal)] mt-2">
132
- Susun paket soal dari bank soal yang tersedia.
133
  </p>
 
 
 
 
134
  </section>
135
 
136
  {step === "select" ? (
137
  <>
138
- {/* Selection Toolbar */}
139
  <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6 sticky top-0 z-30 bg-[var(--warm-cream)] py-4">
140
  <div className="flex gap-3 flex-1 w-full md:w-auto">
141
  <div className="relative flex-1 max-w-md">
142
  <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
143
  <Input
144
- placeholder="Cari soal..."
145
  value={search}
146
  onChange={(e) => setSearch(e.target.value)}
147
  className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
148
  />
149
  </div>
150
- <Select
151
- items={[
152
- { value: "", label: "Semua Ujian" },
153
- ...EXAM_TYPES.map((t) => ({ value: t.id, label: t.name })),
154
- ]}
155
- value={examTypeFilter}
156
- onValueChange={(v: string | null) => setExamTypeFilter(v ?? "")}
157
- >
158
- <SelectTrigger className="w-36">
159
- <SelectValue placeholder="Semua Ujian" />
160
- </SelectTrigger>
161
- <SelectContent>
162
- <SelectGroup>
163
- <SelectItem value="">Semua Ujian</SelectItem>
164
- {EXAM_TYPES.map((t) => (
165
- <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
166
- ))}
167
- </SelectGroup>
168
- </SelectContent>
169
- </Select>
170
  </div>
171
 
172
  <div className="flex items-center gap-3">
173
  <span className="text-sm text-[var(--warm-charcoal)]">
174
- <span className="font-bold text-[var(--clay-black)]">{selectedCount}</span> soal dipilih
175
  </span>
176
  <Button
177
- onClick={() => selectedCount > 0 && setStep("review")}
178
- disabled={selectedCount === 0}
179
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
180
  >
181
  <MaterialIcon name="arrow_forward" />
@@ -184,62 +154,77 @@ function BuilderComponent() {
184
  </div>
185
  </div>
186
 
187
- {/* Question Grid */}
188
- {questionsQuery.isLoading ? (
189
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
190
- {Array.from({ length: 6 }).map((_, i) => (
191
- <Card key={i} className="h-32 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
192
  ))}
193
  </div>
194
- ) : questions.length === 0 ? (
195
  <div className="text-center py-20">
196
- <MaterialIcon name="search_off" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
197
- <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada soal ditemukan</p>
198
  </div>
199
  ) : (
200
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
201
- {questions.map((q) => {
202
- const isSelected = selectedQuestions.includes(q.id);
203
- return (
204
- <div
205
- key={q.id}
206
- onClick={() => toggleQuestion(q.id)}
207
- className={`cursor-pointer rounded-[var(--radius-xl)] border-2 p-5 transition-all clay-hover ${
208
- isSelected
209
- ? "border-[var(--clay-black)] bg-[var(--matcha-300)]/10 clay-shadow"
210
- : "border-[var(--oat-border)] bg-[var(--pure-white)] hover:bg-[var(--oat-light)]"
211
- }`}
212
- >
213
- <div className="flex items-start gap-3">
214
- <div className={`mt-0.5 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
215
- isSelected ? "bg-[var(--clay-black)] border-[var(--clay-black)]" : "border-[var(--oat-border)]"
216
- }`}>
217
- {isSelected && <MaterialIcon name="check" className="text-xs text-[var(--pure-white)]" />}
218
- </div>
219
- <div className="flex-1 min-w-0">
220
- <div className="flex gap-2 flex-wrap mb-2">
221
- <span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
222
- {q.examTypeName}
223
- </span>
224
- <span className="px-2 py-0.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
225
- {q.sectionTypeName}
226
- </span>
227
- <span className="px-2 py-0.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
228
- {formatLabel(q.format)}
229
- </span>
230
- </div>
231
- <p className="text-sm font-medium text-[var(--clay-black)] line-clamp-2">{q.questionText}</p>
232
- <p className="text-xs text-[var(--warm-charcoal)] line-clamp-1 mt-1">{q.passageText}</p>
233
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  </div>
235
- </div>
236
- );
237
- })}
238
  </div>
239
  )}
240
  </>
241
  ) : (
242
- /* Review & Create Step */
243
  <div className="max-w-2xl mx-auto">
244
  <Button
245
  variant="outline"
@@ -252,14 +237,14 @@ function BuilderComponent() {
252
 
253
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
254
  <CardContent className="p-6 space-y-5">
255
- <h2 className="font-headline text-xl font-bold text-[var(--clay-black)]">Detail Paket</h2>
256
 
257
  <div>
258
  <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">Judul</label>
259
  <Input
260
  value={title}
261
  onChange={(e) => setTitle(e.target.value)}
262
- placeholder="e.g. IELTS Reading Practice Set 1"
263
  className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
264
  />
265
  </div>
@@ -274,26 +259,6 @@ function BuilderComponent() {
274
  />
275
  </div>
276
 
277
- <div>
278
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">Jenis Ujian</label>
279
- <Select
280
- items={EXAM_TYPES.map((t) => ({ value: t.id, label: t.name }))}
281
- value={examType}
282
- onValueChange={(v: string | null) => setExamType(v ?? "IELTS")}
283
- >
284
- <SelectTrigger>
285
- <SelectValue placeholder="Pilih ujian" />
286
- </SelectTrigger>
287
- <SelectContent>
288
- <SelectGroup>
289
- {EXAM_TYPES.map((t) => (
290
- <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
291
- ))}
292
- </SelectGroup>
293
- </SelectContent>
294
- </Select>
295
- </div>
296
-
297
  <label className="flex items-center gap-2 cursor-pointer">
298
  <input
299
  type="checkbox"
@@ -309,39 +274,38 @@ function BuilderComponent() {
309
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
310
  <CardContent className="p-6">
311
  <h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4">
312
- Soal Terpilih ({selectedCount})
313
  </h3>
314
  <div className="space-y-3">
315
- {selectedQuestions.map((qid, idx) => {
316
- const q = questions.find((item) => item.id === qid);
317
- if (!q) return null;
318
- return (
319
- <div key={qid} className="flex items-start gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
320
- <span className="w-6 h-6 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0">
321
- {idx + 1}
322
- </span>
323
- <p className="text-sm text-[var(--clay-black)] line-clamp-2 flex-1">{q.questionText}</p>
324
- <button
325
- onClick={() => toggleQuestion(qid)}
326
- className="text-[var(--pomegranate-400)] hover:text-[var(--pomegranate-400)]/80"
327
- >
328
- <MaterialIcon name="close" className="text-sm" />
329
- </button>
330
  </div>
331
- );
332
- })}
 
 
 
 
 
 
333
  </div>
334
  </CardContent>
335
  </Card>
336
 
337
  <Button
338
- onClick={handleCreatePackage}
339
- disabled={!title || createPackage.isPending}
340
  className="w-full py-4 rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] font-bold text-lg clay-shadow clay-hover hover:bg-[var(--warm-charcoal)] h-auto"
341
  >
342
- <MaterialIcon name="folder" />
343
  <span className="ml-2">
344
- {createPackage.isPending ? "Membuat Paket..." : "Buat Paket Soal"}
345
  </span>
346
  </Button>
347
  </div>
 
1
  import { useState } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { Input } from "@labas/ui/components/input";
 
15
  SelectValue,
16
  } from "@labas/ui/components/select";
17
 
18
+ export const Route = createFileRoute("/builder/combo")({
19
+ component: ComboBuilderComponent,
20
  beforeLoad: async () => {
21
  const session = await authClient.getSession();
22
  if (!session.data) {
 
26
  },
27
  });
28
 
 
 
 
 
 
 
 
 
29
  function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
30
  return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
31
  }
32
 
33
+ interface SelectedSection {
34
+ sourcePackageId: string;
35
+ sourceSectionId: string;
36
+ packageTitle: string;
37
+ sectionTitle: string;
38
+ sectionTypeName: string;
39
+ examTypeName: string;
40
  }
41
 
42
+ function ComboBuilderComponent() {
43
  const [step, setStep] = useState<"select" | "review">("select");
44
  const [search, setSearch] = useState("");
45
  const [examTypeFilter, setExamTypeFilter] = useState("");
46
+ const [selectedSections, setSelectedSections] = useState<SelectedSection[]>([]);
47
 
48
  // Package form
49
  const [title, setTitle] = useState("");
50
  const [description, setDescription] = useState("");
 
51
  const [isPublic, setIsPublic] = useState(false);
52
 
53
+ const availableQuery = useQuery(
54
+ trpc.combo.availableSections.queryOptions({
 
55
  examTypeId: examTypeFilter || undefined,
56
+ search: search || undefined,
57
  limit: 50,
58
  offset: 0,
59
  }),
60
  );
61
 
62
+ const createCombo = useMutation({
63
+ ...trpc.combo.create.mutationOptions(),
64
  });
65
 
66
+ const toggleSection = (section: SelectedSection) => {
67
+ setSelectedSections((prev) => {
68
+ const exists = prev.find(
69
+ (s) => s.sourceSectionId === section.sourceSectionId,
70
+ );
71
+ if (exists) {
72
+ return prev.filter((s) => s.sourceSectionId !== section.sourceSectionId);
73
+ }
74
+ return [...prev, section];
75
+ });
 
 
76
  };
77
 
78
+ const handleCreateCombo = async () => {
79
+ if (!title || selectedSections.length === 0) return;
80
 
81
  try {
82
+ await createCombo.mutateAsync({
83
  title,
84
  description,
 
85
  isPublic,
86
+ sections: selectedSections.map((s, i) => ({
87
+ sourcePackageId: s.sourcePackageId,
88
+ sourceSectionId: s.sourceSectionId,
 
 
 
 
 
 
 
 
 
 
 
89
  orderIndex: i,
90
+ })),
91
+ });
92
 
93
+ alert(`Combo paket "${title}" berhasil dibuat!`);
94
+ setSelectedSections([]);
95
  setTitle("");
96
  setDescription("");
97
  setStep("select");
98
  } catch (err: any) {
99
+ alert("Gagal membuat combo: " + err.message);
100
  }
101
  };
102
 
103
+ const sections = availableQuery.data?.sections ?? [];
104
+ const groupedSections = sections.reduce((groups, section) => {
105
+ const key = `${section.examTypeName} β€” ${section.packageTitle}`;
106
+ if (!groups[key]) groups[key] = [];
107
+ groups[key].push(section);
108
+ return groups;
109
+ }, {} as Record<string, typeof sections>);
110
 
111
  return (
112
  <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)]">
113
  <section className="mb-8">
114
  <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
115
+ Package Combiner
116
  </h1>
117
  <p className="text-lg text-[var(--warm-charcoal)] mt-2">
118
+ Gabungkan section dari berbagai paket jadi satu paket ujian baru.
119
  </p>
120
+ <div className="mt-3 p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
121
+ <MaterialIcon name="info" className="text-sm mt-0.5 shrink-0" />
122
+ <span>Pilih section (kelompok soal) dari paket yang sudah ada. Bedanya dengan Builder: disini kamu gabungkan section utuh, bukan pilih soal satu per satu.</span>
123
+ </div>
124
  </section>
125
 
126
  {step === "select" ? (
127
  <>
128
+ {/* Toolbar */}
129
  <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6 sticky top-0 z-30 bg-[var(--warm-cream)] py-4">
130
  <div className="flex gap-3 flex-1 w-full md:w-auto">
131
  <div className="relative flex-1 max-w-md">
132
  <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
133
  <Input
134
+ placeholder="Cari paket..."
135
  value={search}
136
  onChange={(e) => setSearch(e.target.value)}
137
  className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
138
  />
139
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </div>
141
 
142
  <div className="flex items-center gap-3">
143
  <span className="text-sm text-[var(--warm-charcoal)]">
144
+ <span className="font-bold text-[var(--clay-black)]">{selectedSections.length}</span> section dipilih
145
  </span>
146
  <Button
147
+ onClick={() => selectedSections.length > 0 && setStep("review")}
148
+ disabled={selectedSections.length === 0}
149
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
150
  >
151
  <MaterialIcon name="arrow_forward" />
 
154
  </div>
155
  </div>
156
 
157
+ {/* Sections by Package */}
158
+ {availableQuery.isLoading ? (
159
+ <div className="space-y-4">
160
+ {Array.from({ length: 4 }).map((_, i) => (
161
+ <Card key={i} className="h-24 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
162
  ))}
163
  </div>
164
+ ) : Object.keys(groupedSections).length === 0 ? (
165
  <div className="text-center py-20">
166
+ <MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
167
+ <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada section ditemukan</p>
168
  </div>
169
  ) : (
170
+ <div className="space-y-6">
171
+ {Object.entries(groupedSections).map(([groupKey, groupSections]) => (
172
+ <Card key={groupKey} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
173
+ <CardContent className="p-5">
174
+ <h3 className="font-headline font-bold text-[var(--clay-black)] mb-4 text-sm uppercase tracking-wider">
175
+ {groupKey}
176
+ </h3>
177
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
178
+ {groupSections.map((section) => {
179
+ const isSelected = selectedSections.some(
180
+ (s) => s.sourceSectionId === section.id,
181
+ );
182
+ return (
183
+ <div
184
+ key={section.id}
185
+ onClick={() =>
186
+ toggleSection({
187
+ sourcePackageId: section.packageId,
188
+ sourceSectionId: section.id,
189
+ packageTitle: section.packageTitle ?? "Untitled",
190
+ sectionTitle: section.title,
191
+ sectionTypeName: section.sectionTypeName ?? "Unknown",
192
+ examTypeName: section.examTypeName ?? "Unknown",
193
+ })
194
+ }
195
+ className={`cursor-pointer rounded-[var(--radius-lg)] border-2 p-4 transition-all clay-hover ${
196
+ isSelected
197
+ ? "border-[var(--clay-black)] bg-[var(--matcha-300)]/10 clay-shadow"
198
+ : "border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
199
+ }`}
200
+ >
201
+ <div className="flex items-center gap-3">
202
+ <div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
203
+ isSelected ? "bg-[var(--clay-black)] border-[var(--clay-black)]" : "border-[var(--oat-border)]"
204
+ }`}>
205
+ {isSelected && <MaterialIcon name="check" className="text-xs text-[var(--pure-white)]" />}
206
+ </div>
207
+ <div className="flex-1">
208
+ <p className="text-sm font-medium text-[var(--clay-black)]">{section.title}</p>
209
+ <div className="flex gap-2 mt-1">
210
+ <span className="px-2 py-0.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
211
+ {section.sectionTypeName}
212
+ </span>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ })}
219
  </div>
220
+ </CardContent>
221
+ </Card>
222
+ ))}
223
  </div>
224
  )}
225
  </>
226
  ) : (
227
+ /* Review Step */
228
  <div className="max-w-2xl mx-auto">
229
  <Button
230
  variant="outline"
 
237
 
238
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
239
  <CardContent className="p-6 space-y-5">
240
+ <h2 className="font-headline text-xl font-bold text-[var(--clay-black)]">Detail Combo Paket</h2>
241
 
242
  <div>
243
  <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">Judul</label>
244
  <Input
245
  value={title}
246
  onChange={(e) => setTitle(e.target.value)}
247
+ placeholder="e.g. IELTS Mixed Practice"
248
  className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
249
  />
250
  </div>
 
259
  />
260
  </div>
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  <label className="flex items-center gap-2 cursor-pointer">
263
  <input
264
  type="checkbox"
 
274
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
275
  <CardContent className="p-6">
276
  <h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-4">
277
+ Section Terpilih ({selectedSections.length})
278
  </h3>
279
  <div className="space-y-3">
280
+ {selectedSections.map((sec, idx) => (
281
+ <div key={sec.sourceSectionId} className="flex items-start gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
282
+ <span className="w-6 h-6 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0">
283
+ {idx + 1}
284
+ </span>
285
+ <div className="flex-1">
286
+ <p className="text-sm font-medium text-[var(--clay-black)]">{sec.sectionTitle}</p>
287
+ <p className="text-xs text-[var(--warm-charcoal)]">{sec.packageTitle} Β· {sec.examTypeName}</p>
 
 
 
 
 
 
 
288
  </div>
289
+ <button
290
+ onClick={() => toggleSection(sec)}
291
+ className="text-[var(--pomegranate-400)] hover:text-[var(--pomegranate-400)]/80"
292
+ >
293
+ <MaterialIcon name="close" className="text-sm" />
294
+ </button>
295
+ </div>
296
+ ))}
297
  </div>
298
  </CardContent>
299
  </Card>
300
 
301
  <Button
302
+ onClick={handleCreateCombo}
303
+ disabled={!title || createCombo.isPending}
304
  className="w-full py-4 rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] font-bold text-lg clay-shadow clay-hover hover:bg-[var(--warm-charcoal)] h-auto"
305
  >
306
+ <MaterialIcon name="construction" />
307
  <span className="ml-2">
308
+ {createCombo.isPending ? "Membuat Combo..." : "Buat Combo Paket"}
309
  </span>
310
  </Button>
311
  </div>
apps/web/src/routes/generate.tsx CHANGED
@@ -1,5 +1,5 @@
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";
@@ -69,86 +69,87 @@ function RouteComponent() {
69
  const [weaknessAlign, setWeaknessAlign] = useState(75);
70
  const [result, setResult] = useState<GenerationResult | null>(null);
71
  const [error, setError] = useState<string | null>(null);
72
- const [saveMode, setSaveMode] = useState<"questions" | "package">("questions");
73
- const [packageTitle, setPackageTitle] = useState("");
74
- const [packageDesc, setPackageDesc] = useState("");
75
- const [isPublicPackage, setIsPublicPackage] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  const generate = useMutation({
78
  ...trpc.ai.generate.mutationOptions(),
79
  onSuccess: (data) => {
80
- setResult(data);
81
  setError(null);
 
82
  },
83
  onError: (err) => {
84
  setError(err.message);
85
- setResult(null);
86
  },
87
  });
88
 
89
- const saveQuestions = useMutation({
90
- ...trpc.ai.saveQuestions.mutationOptions(),
91
- onSuccess: () => {
92
- alert("Soal berhasil disimpan!");
 
 
 
 
 
 
 
93
  },
94
  });
95
 
96
- const createPackage = useMutation({
97
- ...trpc.package.create.mutationOptions(),
98
- });
99
-
100
- const addSection = useMutation({
101
- ...trpc.package.addSection.mutationOptions(),
102
- });
103
-
104
- const addQuestion = useMutation({
105
- ...trpc.package.addQuestion.mutationOptions(),
106
- });
107
-
108
- const handleSaveAsPackage = async () => {
109
- if (!result || !packageTitle) return;
110
-
111
- try {
112
- // 1. Create package
113
- const pkg = await createPackage.mutateAsync({
114
- title: packageTitle,
115
- description: packageDesc,
116
- examTypeId: examType,
117
- isPublic: isPublicPackage,
118
- estimatedDurationMin: result.questions.length * 2,
119
- });
120
-
121
- // 2. Add section
122
- const sec = await addSection.mutateAsync({
123
- packageId: pkg.id,
124
- sectionTypeId: section,
125
- title: `${SECTIONS.find((s) => s.id === section)?.name} Section`,
126
- orderIndex: 0,
127
- });
128
-
129
- // 3. Save questions and add to section
130
- const saved = await saveQuestions.mutateAsync({
131
- examTypeId: examType,
132
- sectionTypeId: section,
133
- questions: result.questions,
134
- isPublic: isPublicPackage,
135
- });
136
-
137
- for (let i = 0; i < saved.length; i++) {
138
- await addQuestion.mutateAsync({
139
- sectionId: sec.id,
140
- questionId: saved[i].id,
141
- orderIndex: i,
142
- });
143
- }
144
 
145
- alert(`Paket "${packageTitle}" berhasil dibuat!`);
146
- setPackageTitle("");
147
- setPackageDesc("");
148
- } catch (err: any) {
149
- alert("Gagal membuat paket: " + err.message);
 
150
  }
151
- };
 
 
 
 
152
 
153
  const toggleFormat = (id: string) => {
154
  setSelectedFormats((prev) =>
@@ -168,6 +169,10 @@ function RouteComponent() {
168
  return;
169
  }
170
 
 
 
 
 
171
  generate.mutate({
172
  examType: examType as any,
173
  section: section as any,
@@ -175,11 +180,12 @@ function RouteComponent() {
175
  difficulty: difficulty + 1,
176
  topics: selectedTopics,
177
  questionCount,
178
- mode: "quick",
179
  apiKeyConfig: {
180
  baseUrl: storedKey.baseUrl,
181
  apiKey: storedKey.apiKey,
182
  model: storedKey.modelName,
 
183
  },
184
  });
185
  };
@@ -426,16 +432,84 @@ function RouteComponent() {
426
  </p>
427
  </div>
428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  {/* Primary CTA */}
430
  <Button
431
  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"
432
  onClick={handleGenerate}
433
- disabled={generate.isPending || selectedFormats.length === 0 || !hasKey}
434
  >
435
  <MaterialIcon name="auto_awesome" className="group-hover:rotate-12 transition-transform" />
436
- {generate.isPending ? "Generating..." : "Generate & Launch"}
 
 
 
 
437
  </Button>
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  {error && (
440
  <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)]">
441
  {error}
@@ -450,85 +524,32 @@ function RouteComponent() {
450
  {result && (
451
  <div className="mt-16 space-y-6">
452
  <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
453
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h2>
454
-
455
- <div className="flex gap-2">
456
- <Button
457
- onClick={() => setSaveMode(saveMode === "questions" ? "package" : "questions")}
458
- variant="outline"
459
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
460
- >
461
- <MaterialIcon name={saveMode === "package" ? "list" : "folder"} />
462
- <span className="ml-2">{saveMode === "package" ? "Simpan Satuan" : "Simpan Paket"}</span>
463
- </Button>
464
-
465
- {saveMode === "questions" ? (
466
- <Button
467
- onClick={() =>
468
- saveQuestions.mutate({
469
- examTypeId: examType,
470
- sectionTypeId: section,
471
- questions: result.questions,
472
- isPublic: false,
473
- })
474
- }
475
- disabled={saveQuestions.isPending}
476
- className="bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
477
- >
478
- {saveQuestions.isPending ? "Menyimpan..." : "Simpan ke Bank"}
479
- </Button>
480
- ) : (
481
- <Button
482
- onClick={handleSaveAsPackage}
483
- disabled={createPackage.isPending || !packageTitle}
484
- className="bg-[var(--blueberry-800)] text-[var(--pure-white)] hover:bg-[var(--ube-800)] clay-hover rounded-[var(--radius-lg)]"
485
- >
486
- {createPackage.isPending ? "Membuat..." : "Buat Paket"}
487
- </Button>
488
  )}
 
 
 
 
 
 
489
  </div>
490
  </div>
491
 
492
- {/* Package Form */}
493
- {saveMode === "package" && (
494
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
495
- <CardContent className="p-5 space-y-4">
496
- <h3 className="font-headline font-bold text-[var(--clay-black)]">Detail Paket</h3>
497
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
498
- <div>
499
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">Judul Paket</label>
500
- <Input
501
- value={packageTitle}
502
- onChange={(e) => setPackageTitle(e.target.value)}
503
- placeholder="e.g. IELTS Reading - Science & Tech"
504
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
505
- />
506
- </div>
507
- <div>
508
- <label className="text-sm font-medium text-[var(--clay-black)] block mb-1">Deskripsi (opsional)</label>
509
- <Input
510
- value={packageDesc}
511
- onChange={(e) => setPackageDesc(e.target.value)}
512
- placeholder="Deskripsi singkat..."
513
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)]"
514
- />
515
- </div>
516
- </div>
517
- <label className="flex items-center gap-2 cursor-pointer">
518
- <input
519
- type="checkbox"
520
- checked={isPublicPackage}
521
- onChange={(e) => setIsPublicPackage(e.target.checked)}
522
- className="w-4 h-4 rounded border-[var(--oat-border)]"
523
- />
524
- <span className="text-sm text-[var(--warm-charcoal)]">Publikasikan ke Bank Soal</span>
525
- </label>
526
- </CardContent>
527
- </Card>
528
- )}
529
-
530
  <div className="text-sm text-[var(--warm-charcoal)] mb-4 font-mono">
531
- Model: {result.meta.model} Β· Tokens: {result.meta.tokensUsed ?? "?"} Β· Waktu: {result.meta.durationMs}ms
532
  </div>
533
 
534
  {result.questions.map((q, idx) => (
@@ -551,41 +572,18 @@ function RouteComponent() {
551
  {"options" in q && q.options && (
552
  <div className="space-y-2">
553
  {q.options.map((opt) => (
554
- <label
555
  key={opt.key}
556
- className={`flex items-center p-4 rounded-[var(--radius-lg)] border-2 cursor-pointer transition-all clay-hover ${
557
- opt.key === q.correctAnswer
558
- ? "border-[var(--clay-black)] bg-[var(--oat-light)]"
559
- : "border-[var(--oat-border)] bg-[var(--oat-light)] hover:bg-[var(--matcha-300)]/30"
560
- }`}
561
  >
562
- <span className="w-5 h-5 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-4">
563
- {opt.key === q.correctAnswer && (
564
- <span className="w-2.5 h-2.5 rounded-full bg-[var(--clay-black)]" />
565
- )}
566
  </span>
567
- <span className={`${opt.key === q.correctAnswer ? "font-semibold text-[var(--clay-black)]" : "text-[var(--clay-black)]"}`}>
568
- {opt.text}
569
- </span>
570
- {opt.key === q.correctAnswer && (
571
- <span className="ml-auto text-xs font-bold text-[var(--matcha-800)] bg-[var(--matcha-300)] px-2 py-1 rounded-full">
572
- Benar
573
- </span>
574
- )}
575
- </label>
576
  ))}
577
  </div>
578
  )}
579
- {!("options" in q) && (
580
- <div className="p-4 rounded-[var(--radius-md)] border-2 border-[var(--matcha-300)] bg-[var(--matcha-300)]/30 text-sm">
581
- <span className="font-semibold text-[var(--matcha-800)]">Jawaban: </span>
582
- <span className="text-[var(--matcha-800)]">{q.correctAnswer}</span>
583
- </div>
584
- )}
585
- <div className="text-sm text-[var(--warm-charcoal)]">
586
- <span className="font-medium text-[var(--clay-black)]">Penjelasan: </span>
587
- {q.explanation}
588
- </div>
589
  <div className="flex gap-2 flex-wrap">
590
  {q.skillTags.map((tag) => (
591
  <span
 
1
+ import { useState, useEffect } from "react";
2
+ import { useMutation, useQuery } 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";
 
69
  const [weaknessAlign, setWeaknessAlign] = useState(75);
70
  const [result, setResult] = useState<GenerationResult | null>(null);
71
  const [error, setError] = useState<string | null>(null);
72
+ const [mode, setMode] = useState<"quick" | "agentic">("quick");
73
+ const [jobId, setJobIdState] = useState<string | null>(null);
74
+ const [generatedPackageId, setGeneratedPackageId] = useState<string | null>(null);
75
+
76
+ // Persist jobId to sessionStorage so it survives navigation
77
+ const setJobId = (id: string | null) => {
78
+ setJobIdState(id);
79
+ if (id) {
80
+ sessionStorage.setItem("labas_active_job", id);
81
+ } else {
82
+ sessionStorage.removeItem("labas_active_job");
83
+ }
84
+ };
85
+
86
+ // On mount: recover from sessionStorage
87
+ useEffect(() => {
88
+ const saved = sessionStorage.getItem("labas_active_job");
89
+ if (saved) {
90
+ setJobIdState(saved);
91
+ }
92
+ }, []);
93
+
94
+ // Also check myJobs for any pending/running jobs (fallback / cross-tab sync)
95
+ const myJobsQuery = useQuery(
96
+ trpc.ai.myJobs.queryOptions({ limit: 10, offset: 0 }),
97
+ );
98
+
99
+ useEffect(() => {
100
+ if (!jobId && myJobsQuery.data) {
101
+ const active = myJobsQuery.data.find(
102
+ (j: any) => j.status === "pending" || j.status === "running",
103
+ );
104
+ if (active) {
105
+ setJobId(active.id);
106
+ }
107
+ }
108
+ }, [myJobsQuery.data, jobId]);
109
 
110
  const generate = useMutation({
111
  ...trpc.ai.generate.mutationOptions(),
112
  onSuccess: (data) => {
113
+ setJobId(data.jobId);
114
  setError(null);
115
+ setResult(null);
116
  },
117
  onError: (err) => {
118
  setError(err.message);
119
+ setJobId(null);
120
  },
121
  });
122
 
123
+ // Poll job status
124
+ const jobQuery = useQuery({
125
+ ...trpc.ai.getJobStatus.queryOptions(
126
+ { jobId: jobId! },
127
+ { enabled: !!jobId },
128
+ ),
129
+ refetchInterval: (query) => {
130
+ const data = query.state.data;
131
+ if (!data) return 1000;
132
+ if (data.status === "completed" || data.status === "failed") return false;
133
+ return 1000;
134
  },
135
  });
136
 
137
+ const isGenerating =
138
+ jobId !== null &&
139
+ (!jobQuery.data || (jobQuery.data.status !== "completed" && jobQuery.data.status !== "failed"));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
+ useEffect(() => {
142
+ if (jobQuery.data?.status === "completed" && jobQuery.data.resultJson) {
143
+ const res = jobQuery.data.resultJson as GenerationResult & { generatedPackageId?: string | null };
144
+ setResult(res);
145
+ setGeneratedPackageId(res.generatedPackageId ?? null);
146
+ setJobId(null);
147
  }
148
+ if (jobQuery.data?.status === "failed") {
149
+ setError(jobQuery.data.errorMessage ?? "Generation failed");
150
+ setJobId(null);
151
+ }
152
+ }, [jobQuery.data]);
153
 
154
  const toggleFormat = (id: string) => {
155
  setSelectedFormats((prev) =>
 
169
  return;
170
  }
171
 
172
+ setResult(null);
173
+ setError(null);
174
+ setJobId(null);
175
+
176
  generate.mutate({
177
  examType: examType as any,
178
  section: section as any,
 
180
  difficulty: difficulty + 1,
181
  topics: selectedTopics,
182
  questionCount,
183
+ mode,
184
  apiKeyConfig: {
185
  baseUrl: storedKey.baseUrl,
186
  apiKey: storedKey.apiKey,
187
  model: storedKey.modelName,
188
+ maxTokens: storedKey.maxTokens ?? 16384,
189
  },
190
  });
191
  };
 
432
  </p>
433
  </div>
434
 
435
+ {/* Mode Toggle */}
436
+ <div className="flex gap-1 p-1 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
437
+ <button
438
+ onClick={() => setMode("quick")}
439
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
440
+ mode === "quick"
441
+ ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
442
+ : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
443
+ }`}
444
+ >
445
+ <MaterialIcon name="flash_on" className="text-sm mr-1" />
446
+ Quick
447
+ </button>
448
+ <button
449
+ onClick={() => setMode("agentic")}
450
+ className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all ${
451
+ mode === "agentic"
452
+ ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
453
+ : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
454
+ }`}
455
+ >
456
+ <MaterialIcon name="psychology" className="text-sm mr-1" />
457
+ Agentic
458
+ </button>
459
+ </div>
460
+
461
+ {mode === "agentic" && (
462
+ <div className="p-3 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] border-2 border-[var(--badge-blue-bg)] text-xs text-[var(--badge-blue-text)]">
463
+ <div className="flex items-center gap-2 mb-1">
464
+ <MaterialIcon name="info" className="text-sm" />
465
+ <span className="font-semibold">Mode Agentic</span>
466
+ </div>
467
+ <p>Multi-step validation: passage β†’ validate β†’ questions β†’ self-check β†’ quality score. Lebih lambat tapi kualitas lebih terjamin.</p>
468
+ </div>
469
+ )}
470
+
471
  {/* Primary CTA */}
472
  <Button
473
  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"
474
  onClick={handleGenerate}
475
+ disabled={isGenerating || generate.isPending || selectedFormats.length === 0 || !hasKey}
476
  >
477
  <MaterialIcon name="auto_awesome" className="group-hover:rotate-12 transition-transform" />
478
+ {isGenerating
479
+ ? mode === "agentic"
480
+ ? `Agentic... ${jobQuery.data?.progress ?? 0}%`
481
+ : `Generating... ${jobQuery.data?.progress ?? 0}%`
482
+ : "Generate & Launch"}
483
  </Button>
484
 
485
+ {/* Progress */}
486
+ {isGenerating && (
487
+ <div className="space-y-2">
488
+ <div className="flex items-center gap-3 p-3 rounded-[var(--radius-md)] bg-[var(--matcha-300)]/20 border-2 border-[var(--matcha-300)]">
489
+ <MaterialIcon name="psychology" className="text-[var(--matcha-600)] animate-pulse" />
490
+ <div>
491
+ <p className="text-sm font-semibold text-[var(--matcha-800)]">
492
+ {jobQuery.data?.progressMessage ?? "Sedang berjalan..."}
493
+ </p>
494
+ <p className="text-xs text-[var(--warm-charcoal)]">
495
+ {mode === "agentic"
496
+ ? "Passage β†’ Validate β†’ Questions β†’ Self-Check β†’ Score"
497
+ : "Quick generation mode"}
498
+ </p>
499
+ </div>
500
+ </div>
501
+ <div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
502
+ <div
503
+ className="h-full bg-[var(--matcha-600)] transition-all duration-500"
504
+ style={{ width: `${jobQuery.data?.progress ?? 5}%` }}
505
+ />
506
+ </div>
507
+ <p className="text-xs text-[var(--warm-charcoal)] text-center">
508
+ Bisa ditinggal β€” hasil akan muncul otomatis
509
+ </p>
510
+ </div>
511
+ )}
512
+
513
  {error && (
514
  <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)]">
515
  {error}
 
524
  {result && (
525
  <div className="mt-16 space-y-6">
526
  <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
527
+ <div>
528
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h2>
529
+ <p className="text-sm text-[var(--warm-charcoal)] mt-1">
530
+ Paket latihan berhasil dibuat! Soal juga tersimpan di Bank Soal (privat). Jawaban & penjelasan disembunyikan agar latihan tetap fair.
531
+ </p>
532
+ </div>
533
+ <div className="flex gap-3">
534
+ {generatedPackageId && (
535
+ <Link to="/package/$id" params={{ id: generatedPackageId }}>
536
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
537
+ <MaterialIcon name="play_arrow" className="mr-2" />
538
+ Lihat Paket
539
+ </Button>
540
+ </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  )}
542
+ <Link to="/bank">
543
+ <Button className="bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]">
544
+ <MaterialIcon name="database" className="mr-2" />
545
+ Bank Soal
546
+ </Button>
547
+ </Link>
548
  </div>
549
  </div>
550
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  <div className="text-sm text-[var(--warm-charcoal)] mb-4 font-mono">
552
+ Model: {result.meta.model} Β· Tokens: {result.meta.tokensUsed ?? "?"} Β· Waktu: {result.meta.durationMs}ms Β· {result.questions.length} soal
553
  </div>
554
 
555
  {result.questions.map((q, idx) => (
 
572
  {"options" in q && q.options && (
573
  <div className="space-y-2">
574
  {q.options.map((opt) => (
575
+ <div
576
  key={opt.key}
577
+ className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
 
 
 
 
578
  >
579
+ <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
580
+ {opt.key}
 
 
581
  </span>
582
+ <span className="text-[var(--clay-black)]">{opt.text}</span>
583
+ </div>
 
 
 
 
 
 
 
584
  ))}
585
  </div>
586
  )}
 
 
 
 
 
 
 
 
 
 
587
  <div className="flex gap-2 flex-wrap">
588
  {q.skillTags.map((tag) => (
589
  <span
apps/web/src/routes/jobs.tsx ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { useQuery } 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 { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
7
+ import { Button } from "@labas/ui/components/button";
8
+ import type { GenerationResult } from "@labas/ai";
9
+
10
+ export const Route = createFileRoute("/jobs")({
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, className = "" }: { name: string; className?: string }) {
22
+ return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
23
+ }
24
+
25
+ const STATUS_COLORS: Record<string, string> = {
26
+ pending: "bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)]",
27
+ running: "bg-[var(--matcha-300)] text-[var(--matcha-800)]",
28
+ completed: "bg-[var(--lemon-300)] text-[var(--lemon-800)]",
29
+ failed: "bg-[var(--pomegranate-400)]/20 text-[var(--pomegranate-400)]",
30
+ };
31
+
32
+ const STATUS_ICONS: Record<string, string> = {
33
+ pending: "hourglass_empty",
34
+ running: "sync",
35
+ completed: "check_circle",
36
+ failed: "error",
37
+ };
38
+
39
+ function formatDate(d: string | Date | null) {
40
+ if (!d) return "β€”";
41
+ return new Date(d).toLocaleString("id-ID", {
42
+ day: "numeric",
43
+ month: "short",
44
+ year: "numeric",
45
+ hour: "2-digit",
46
+ minute: "2-digit",
47
+ });
48
+ }
49
+
50
+ function RouteComponent() {
51
+ const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
52
+
53
+ const jobsQuery = useQuery(trpc.ai.myJobs.queryOptions({ limit: 50, offset: 0 }));
54
+
55
+ const toggleExpand = (id: string) => {
56
+ setExpandedJobId((prev) => (prev === id ? null : id));
57
+ };
58
+
59
+ return (
60
+ <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)]">
61
+ <section className="mb-10">
62
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
63
+ Riwayat Generasi
64
+ </h1>
65
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">
66
+ Pantau dan kelola proses generate soal AI-mu.
67
+ </p>
68
+ </section>
69
+
70
+ {jobsQuery.isLoading ? (
71
+ <div className="flex items-center gap-3 text-[var(--warm-charcoal)]">
72
+ <MaterialIcon name="sync" className="animate-spin" />
73
+ Memuat riwayat...
74
+ </div>
75
+ ) : jobsQuery.isError ? (
76
+ <div className="p-4 rounded-[var(--radius-md)] bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] border-2 border-[var(--pomegranate-400)]/20">
77
+ Gagal memuat riwayat: {jobsQuery.error.message}
78
+ </div>
79
+ ) : !jobsQuery.data?.length ? (
80
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
81
+ <CardContent className="py-16 text-center">
82
+ <MaterialIcon name="schedule" className="text-5xl text-[var(--oat-border)] mb-4" />
83
+ <p className="text-[var(--warm-charcoal)] text-lg">Belum ada riwayat generasi.</p>
84
+ <p className="text-sm text-[var(--warm-charcoal)] mt-1">
85
+ Mulai generate soal di{" "}
86
+ <Link to="/generate" className="text-[var(--matcha-600)] font-semibold underline">
87
+ AI Lab
88
+ </Link>
89
+ .
90
+ </p>
91
+ </CardContent>
92
+ </Card>
93
+ ) : (
94
+ <div className="space-y-4">
95
+ {jobsQuery.data.map((job) => {
96
+ const isExpanded = expandedJobId === job.id;
97
+ const result = job.resultJson as GenerationResult | null;
98
+
99
+ return (
100
+ <Card
101
+ key={job.id}
102
+ className={`clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] transition-all ${
103
+ isExpanded ? "ring-2 ring-[var(--matcha-300)]" : ""
104
+ }`}
105
+ >
106
+ <CardHeader className="pb-3">
107
+ <div className="flex items-center justify-between">
108
+ <div className="flex items-center gap-3">
109
+ <div
110
+ className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs font-semibold ${
111
+ STATUS_COLORS[job.status] ?? "bg-gray-100 text-gray-600"
112
+ }`}
113
+ >
114
+ <MaterialIcon name={STATUS_ICONS[job.status] ?? "help"} className="text-sm" />
115
+ {job.status.toUpperCase()}
116
+ </div>
117
+ <div className="text-sm text-[var(--warm-charcoal)]">
118
+ {job.examTypeId} Β· {job.sectionTypeId} Β· {job.questionCount} soal Β·{" "}
119
+ {job.mode === "agentic" ? "Agentic" : "Quick"}
120
+ </div>
121
+ </div>
122
+ <div className="text-xs text-[var(--warm-charcoal)]">
123
+ {formatDate(job.createdAt)}
124
+ </div>
125
+ </div>
126
+
127
+ {job.status === "running" && (
128
+ <div className="mt-3">
129
+ <div className="flex justify-between text-xs text-[var(--warm-charcoal)] mb-1">
130
+ <span>{job.progressMessage ?? "Processing..."}</span>
131
+ <span>{job.progress}%</span>
132
+ </div>
133
+ <div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
134
+ <div
135
+ className="h-full bg-[var(--matcha-600)] transition-all duration-500"
136
+ style={{ width: `${job.progress}%` }}
137
+ />
138
+ </div>
139
+ </div>
140
+ )}
141
+ </CardHeader>
142
+
143
+ {job.status === "completed" && result && (
144
+ <CardContent className="pt-0">
145
+ <div className="flex items-center justify-between mb-3">
146
+ <div className="text-sm text-[var(--warm-charcoal)]">
147
+ {result.questions.length} soal dihasilkan Β· {result.meta.model}
148
+ {result.meta.tokensUsed ? ` Β· ${result.meta.tokensUsed} tokens` : ""}
149
+ {result.meta.durationMs ? ` Β· ${(result.meta.durationMs / 1000).toFixed(1)}s` : ""}
150
+ </div>
151
+ <Button
152
+ variant="outline"
153
+ size="sm"
154
+ onClick={() => toggleExpand(job.id)}
155
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] text-xs"
156
+ >
157
+ <MaterialIcon name={isExpanded ? "expand_less" : "expand_more"} className="text-sm mr-1" />
158
+ {isExpanded ? "Tutup" : "Lihat Soal"}
159
+ </Button>
160
+ </div>
161
+
162
+ {isExpanded && (
163
+ <div className="space-y-3 mt-4 border-t-2 border-[var(--oat-border)] pt-4">
164
+ {result.questions.map((q, i) => (
165
+ <div
166
+ key={i}
167
+ className="p-4 rounded-[var(--radius-lg)] bg-[var(--warm-cream)] border-2 border-[var(--oat-border)]"
168
+ >
169
+ <div className="flex items-center gap-2 mb-2">
170
+ <span className="text-xs font-bold px-2 py-0.5 rounded bg-[var(--matcha-300)] text-[var(--matcha-800)]">
171
+ {q.format.replace(/_/g, " ")}
172
+ </span>
173
+ <span className="text-xs text-[var(--warm-charcoal)]">
174
+ Difficulty {q.difficulty}/5
175
+ </span>
176
+ </div>
177
+ <p className="text-sm font-medium text-[var(--clay-black)] mb-2">
178
+ {i + 1}. {q.questionText}
179
+ </p>
180
+ {"options" in q && Array.isArray(q.options) && q.options.length > 0 && (
181
+ <div className="space-y-1 mb-2">
182
+ {q.options.map((opt: any) => (
183
+ <div
184
+ key={opt.key}
185
+ className={`text-sm px-3 py-1.5 rounded-[var(--radius-md)] border ${
186
+ // opt.key === q.correctAnswer
187
+ // ? "bg-[var(--matcha-300)]/30 border-[var(--matcha-300)] text-[var(--matcha-800)] font-medium"
188
+ // : "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
189
+ "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
190
+ }`}
191
+ >
192
+ {opt.key}. {opt.text}
193
+ </div>
194
+ ))}
195
+ </div>
196
+ )}
197
+ {/* <p className="text-xs text-[var(--warm-charcoal)]">
198
+ <span className="font-semibold">Jawaban:</span> {q.correctAnswer}
199
+ </p>
200
+ <p className="text-xs text-[var(--warm-charcoal)] mt-1">
201
+ <span className="font-semibold">Penjelasan:</span> {q.explanation}
202
+ </p> */}
203
+ </div>
204
+ ))}
205
+ </div>
206
+ )}
207
+ </CardContent>
208
+ )}
209
+
210
+ {job.status === "failed" && (
211
+ <CardContent className="pt-0">
212
+ <div className="p-3 rounded-[var(--radius-md)] bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] text-sm border-2 border-[var(--pomegranate-400)]/20">
213
+ <MaterialIcon name="error" className="text-sm mr-1" />
214
+ {job.errorMessage ?? "Generation failed"}
215
+ </div>
216
+ </CardContent>
217
+ )}
218
+ </Card>
219
+ );
220
+ })}
221
+ </div>
222
+ )}
223
+ </div>
224
+ );
225
+ }
apps/web/src/routes/package.$id.take.tsx ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { useQuery, useMutation } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { trpc } from "@/utils/trpc";
6
+ import { Button } from "@labas/ui/components/button";
7
+ import { Card, CardContent } from "@labas/ui/components/card";
8
+ import { Input } from "@labas/ui/components/input";
9
+ import { Label } from "@labas/ui/components/label";
10
+
11
+ export const Route = createFileRoute("/package/$id/take")({
12
+ component: TakeTestComponent,
13
+ beforeLoad: async () => {
14
+ const session = await authClient.getSession();
15
+ if (!session.data) {
16
+ redirect({ to: "/login", throw: true });
17
+ }
18
+ return { session };
19
+ },
20
+ });
21
+
22
+ function MaterialIcon({ name, className = "" }: { name: string; className?: string }) {
23
+ return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
24
+ }
25
+
26
+ function formatTime(totalSeconds: number) {
27
+ const m = Math.floor(totalSeconds / 60);
28
+ const s = totalSeconds % 60;
29
+ return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
30
+ }
31
+
32
+ function QuestionInput({
33
+ question,
34
+ value,
35
+ onChange,
36
+ disabled,
37
+ }: {
38
+ question: any;
39
+ value: string;
40
+ onChange: (val: string) => void;
41
+ disabled?: boolean;
42
+ }) {
43
+ const format = question.format;
44
+ const options = question.options as Array<{ key: string; text: string }> | undefined;
45
+
46
+ const radioClass =
47
+ "flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] cursor-pointer hover:border-[var(--matcha-400)] transition-colors";
48
+ const radioSelected = "border-[var(--matcha-600)] bg-[var(--matcha-100)]";
49
+ const radioDisabled = "opacity-60 cursor-not-allowed";
50
+
51
+ if (
52
+ format === "multiple_choice" ||
53
+ format === "synonym" ||
54
+ format === "grammar_in_context" ||
55
+ format === "sentence_completion" ||
56
+ format === "reference" ||
57
+ format === "kanji_reading" ||
58
+ format === "particle_choice" ||
59
+ format === "article_case" ||
60
+ format === "matching_headings" ||
61
+ format === "matching_information" ||
62
+ format === "summary_completion" ||
63
+ format === "cloze"
64
+ ) {
65
+ if (!options || options.length === 0) {
66
+ return (
67
+ <div className="text-sm text-[var(--warm-silver)] italic">
68
+ Tidak ada opsi tersedia untuk soal ini.
69
+ </div>
70
+ );
71
+ }
72
+ return (
73
+ <div className="space-y-2">
74
+ {options.map((opt) => (
75
+ <label
76
+ key={opt.key}
77
+ className={`${radioClass} ${value === opt.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
78
+ >
79
+ <input
80
+ type="radio"
81
+ name={question.id}
82
+ value={opt.key}
83
+ checked={value === opt.key}
84
+ onChange={() => onChange(opt.key)}
85
+ disabled={disabled}
86
+ className="hidden"
87
+ />
88
+ <span className="w-8 h-8 rounded-full bg-[var(--oat-light)] text-[var(--clay-black)] text-sm font-bold flex items-center justify-center shrink-0">
89
+ {opt.key}
90
+ </span>
91
+ <span className="text-sm text-[var(--clay-black)]">{opt.text}</span>
92
+ </label>
93
+ ))}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ if (format === "true_false_not_given") {
99
+ const choices = [
100
+ { key: "TRUE", label: "True" },
101
+ { key: "FALSE", label: "False" },
102
+ { key: "NOT_GIVEN", label: "Not Given" },
103
+ ];
104
+ return (
105
+ <div className="space-y-2">
106
+ {choices.map((c) => (
107
+ <label
108
+ key={c.key}
109
+ className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
110
+ >
111
+ <input
112
+ type="radio"
113
+ name={question.id}
114
+ value={c.key}
115
+ checked={value === c.key}
116
+ onChange={() => onChange(c.key)}
117
+ disabled={disabled}
118
+ className="hidden"
119
+ />
120
+ <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
121
+ </label>
122
+ ))}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ if (format === "author_view") {
128
+ const choices = [
129
+ { key: "YES", label: "Yes" },
130
+ { key: "NO", label: "No" },
131
+ { key: "NOT_GIVEN", label: "Not Given" },
132
+ ];
133
+ return (
134
+ <div className="space-y-2">
135
+ {choices.map((c) => (
136
+ <label
137
+ key={c.key}
138
+ className={`${radioClass} ${value === c.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`}
139
+ >
140
+ <input
141
+ type="radio"
142
+ name={question.id}
143
+ value={c.key}
144
+ checked={value === c.key}
145
+ onChange={() => onChange(c.key)}
146
+ disabled={disabled}
147
+ className="hidden"
148
+ />
149
+ <span className="text-sm font-semibold text-[var(--clay-black)]">{c.label}</span>
150
+ </label>
151
+ ))}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ if (format === "fill_blank") {
157
+ return (
158
+ <Input
159
+ value={value}
160
+ onChange={(e) => onChange(e.target.value)}
161
+ disabled={disabled}
162
+ placeholder="Ketik jawaban Anda..."
163
+ className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]"
164
+ />
165
+ );
166
+ }
167
+
168
+ // Fallback for any unrecognized format
169
+ return (
170
+ <Input
171
+ value={value}
172
+ onChange={(e) => onChange(e.target.value)}
173
+ disabled={disabled}
174
+ placeholder="Ketik jawaban Anda..."
175
+ className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]"
176
+ />
177
+ );
178
+ }
179
+
180
+ function TakeTestComponent() {
181
+ const { id: packageId } = Route.useParams();
182
+ const navigate = useNavigate();
183
+ const { data: session } = authClient.useSession();
184
+
185
+ const packageQuery = useQuery(trpc.package.getById.queryOptions({ id: packageId }));
186
+ const pkg = packageQuery.data;
187
+
188
+ const [attemptId, setAttemptId] = useState<string | null>(null);
189
+ const [currentSectionIdx, setCurrentSectionIdx] = useState(0);
190
+ const [answers, setAnswers] = useState<Record<string, string>>({});
191
+ const [timeElapsed, setTimeElapsed] = useState(0);
192
+ const [isStarted, setIsStarted] = useState(false);
193
+ const [isFinished, setIsFinished] = useState(false);
194
+ const [submittingQId, setSubmittingQId] = useState<string | null>(null);
195
+
196
+ const startMutation = useMutation(trpc.attempt.start.mutationOptions());
197
+ const submitMutation = useMutation(trpc.attempt.submitAnswer.mutationOptions());
198
+ const finishMutation = useMutation(trpc.attempt.finish.mutationOptions());
199
+
200
+ // Timer
201
+ useEffect(() => {
202
+ if (!isStarted || isFinished) return;
203
+ const interval = setInterval(() => {
204
+ setTimeElapsed((t) => t + 1);
205
+ }, 1000);
206
+ return () => clearInterval(interval);
207
+ }, [isStarted, isFinished]);
208
+
209
+ const handleStart = useCallback(async () => {
210
+ if (!pkg) return;
211
+ const res = await startMutation.mutateAsync({ packageId });
212
+ setAttemptId(res.attemptId);
213
+ setIsStarted(true);
214
+ }, [pkg, packageId, startMutation]);
215
+
216
+ const handleAnswerChange = useCallback(
217
+ async (questionId: string, sectionResultId: string, value: string) => {
218
+ if (!attemptId || isFinished) return;
219
+ setAnswers((prev) => ({ ...prev, [questionId]: value }));
220
+ setSubmittingQId(questionId);
221
+ try {
222
+ await submitMutation.mutateAsync({
223
+ attemptId,
224
+ sectionResultId,
225
+ questionId,
226
+ userAnswer: value,
227
+ });
228
+ } finally {
229
+ setSubmittingQId(null);
230
+ }
231
+ },
232
+ [attemptId, isFinished, submitMutation],
233
+ );
234
+
235
+ const handleFinish = useCallback(async () => {
236
+ if (!attemptId) return;
237
+ setIsFinished(true);
238
+ const res = await finishMutation.mutateAsync({ attemptId });
239
+ navigate({ to: "/attempt/$id", params: { id: attemptId } });
240
+ }, [attemptId, finishMutation, navigate]);
241
+
242
+ if (packageQuery.isLoading) {
243
+ return (
244
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
245
+ <div className="h-8 w-48 bg-[var(--oat-light)] animate-pulse rounded mb-4" />
246
+ <div className="h-64 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
247
+ </div>
248
+ );
249
+ }
250
+
251
+ if (!pkg) {
252
+ return (
253
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
254
+ <div className="text-center py-20">
255
+ <MaterialIcon name="error_outline" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
256
+ <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Paket tidak ditemukan</p>
257
+ <Link to="/packages" className="text-[var(--matcha-600)] font-semibold mt-4 inline-block">
258
+ Kembali ke Paket
259
+ </Link>
260
+ </div>
261
+ </div>
262
+ );
263
+ }
264
+
265
+ const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
266
+ const answeredCount = Object.keys(answers).length;
267
+
268
+ // Start screen
269
+ if (!isStarted) {
270
+ return (
271
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-3xl mx-auto bg-[var(--warm-cream)]">
272
+ <div className="mb-8">
273
+ <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-6">
274
+ <Link to="/packages" className="hover:text-[var(--clay-black)] transition-colors">
275
+ Paket
276
+ </Link>
277
+ <MaterialIcon name="chevron_right" className="text-xs" />
278
+ <Link to="/package/$id" params={{ id: packageId }} className="hover:text-[var(--clay-black)] transition-colors">
279
+ {pkg.title}
280
+ </Link>
281
+ <MaterialIcon name="chevron_right" className="text-xs" />
282
+ <span className="text-[var(--clay-black)] font-medium">Latihan</span>
283
+ </div>
284
+
285
+ <h1 className="text-3xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight mb-4">
286
+ {pkg.title}
287
+ </h1>
288
+ <p className="text-[var(--warm-charcoal)] mb-8">
289
+ Persiapkan diri Anda. Setelah memulai, timer akan berjalan dan jawaban tersimpan otomatis.
290
+ </p>
291
+
292
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-8">
293
+ <CardContent className="p-6 space-y-4">
294
+ <div className="flex items-center gap-3">
295
+ <MaterialIcon name="quiz" className="text-[var(--matcha-600)]" />
296
+ <span className="text-[var(--clay-black)] font-medium">{totalQuestions} soal</span>
297
+ </div>
298
+ <div className="flex items-center gap-3">
299
+ <MaterialIcon name="folder" className="text-[var(--matcha-600)]" />
300
+ <span className="text-[var(--clay-black)] font-medium">{pkg.totalSections} section</span>
301
+ </div>
302
+ {pkg.estimatedDurationMin && (
303
+ <div className="flex items-center gap-3">
304
+ <MaterialIcon name="timer" className="text-[var(--matcha-600)]" />
305
+ <span className="text-[var(--clay-black)] font-medium">
306
+ Estimasi {pkg.estimatedDurationMin} menit
307
+ </span>
308
+ </div>
309
+ )}
310
+ </CardContent>
311
+ </Card>
312
+
313
+ <Button
314
+ onClick={handleStart}
315
+ disabled={startMutation.isPending}
316
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] px-8 py-6 text-lg"
317
+ >
318
+ <MaterialIcon name="play_arrow" />
319
+ <span className="ml-2">{startMutation.isPending ? "Memulai..." : "Mulai Latihan"}</span>
320
+ </Button>
321
+ </div>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ const currentSection = pkg.sections[currentSectionIdx];
327
+ if (!currentSection) {
328
+ return (
329
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
330
+ <div className="text-center py-20">
331
+ <MaterialIcon name="check_circle" className="text-6xl text-[var(--matcha-600)] mx-auto mb-4" />
332
+ <p className="text-xl font-headline font-bold text-[var(--clay-black)]">Semua section selesai!</p>
333
+ <Button onClick={handleFinish} className="mt-6 bg-[var(--clay-black)] text-[var(--pure-white)] clay-hover rounded-[var(--radius-lg)]">
334
+ Selesaikan & Lihat Hasil
335
+ </Button>
336
+ </div>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ // Find the sectionResultId for this section
342
+ // We don't have it directly here since we didn't fetch attempt data.
343
+ // We need to fetch attempt data after starting.
344
+ // Actually, for the MVP, we can just use a placeholder or fetch attempt data.
345
+ // Let me add an attempt query.
346
+
347
+ return (
348
+ <AttemptTestView
349
+ attemptId={attemptId!}
350
+ pkg={pkg}
351
+ currentSectionIdx={currentSectionIdx}
352
+ setCurrentSectionIdx={setCurrentSectionIdx}
353
+ answers={answers}
354
+ onAnswerChange={handleAnswerChange}
355
+ timeElapsed={timeElapsed}
356
+ answeredCount={answeredCount}
357
+ totalQuestions={totalQuestions}
358
+ onFinish={handleFinish}
359
+ isFinished={isFinished}
360
+ submittingQId={submittingQId}
361
+ />
362
+ );
363
+ }
364
+
365
+ function AttemptTestView({
366
+ attemptId,
367
+ pkg,
368
+ currentSectionIdx,
369
+ setCurrentSectionIdx,
370
+ answers,
371
+ onAnswerChange,
372
+ timeElapsed,
373
+ answeredCount,
374
+ totalQuestions,
375
+ onFinish,
376
+ isFinished,
377
+ submittingQId,
378
+ }: {
379
+ attemptId: string;
380
+ pkg: any;
381
+ currentSectionIdx: number;
382
+ setCurrentSectionIdx: (idx: number) => void;
383
+ answers: Record<string, string>;
384
+ onAnswerChange: (questionId: string, sectionResultId: string, value: string) => void;
385
+ timeElapsed: number;
386
+ answeredCount: number;
387
+ totalQuestions: number;
388
+ onFinish: () => void;
389
+ isFinished: boolean;
390
+ submittingQId: string | null;
391
+ }) {
392
+ const attemptQuery = useQuery(trpc.attempt.getById.queryOptions({ id: attemptId }));
393
+
394
+ const attempt = attemptQuery.data;
395
+ const currentSection = pkg.sections[currentSectionIdx];
396
+ const sectionData = attempt?.sections?.[currentSectionIdx];
397
+ const sectionResultId = sectionData?.sectionResultId;
398
+
399
+ return (
400
+ <div className="min-h-screen bg-[var(--warm-cream)]">
401
+ {/* Top Bar */}
402
+ <div className="sticky top-0 z-50 bg-[var(--pure-white)] border-b-2 border-[var(--oat-border)] px-4 md:px-8 py-3">
403
+ <div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
404
+ <div className="flex items-center gap-3">
405
+ <Link to="/package/$id" params={{ id: pkg.id }} className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]">
406
+ <MaterialIcon name="close" />
407
+ </Link>
408
+ <h1 className="font-headline font-bold text-[var(--clay-black)] text-sm md:text-base truncate max-w-[200px] md:max-w-sm">
409
+ {pkg.title}
410
+ </h1>
411
+ </div>
412
+
413
+ <div className="flex items-center gap-4">
414
+ <div className="flex items-center gap-1.5 bg-[var(--oat-light)] px-3 py-1.5 rounded-full text-sm font-mono text-[var(--clay-black)]">
415
+ <MaterialIcon name="timer" className="text-sm" />
416
+ {formatTime(timeElapsed)}
417
+ </div>
418
+ <div className="hidden md:flex items-center gap-1.5 text-sm text-[var(--warm-charcoal)]">
419
+ <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
420
+ {answeredCount}/{totalQuestions}
421
+ </div>
422
+ <Button
423
+ onClick={onFinish}
424
+ disabled={isFinished}
425
+ className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)] text-sm px-4 py-2"
426
+ >
427
+ Selesai
428
+ </Button>
429
+ </div>
430
+ </div>
431
+ </div>
432
+
433
+ {/* Section Tabs */}
434
+ <div className="max-w-5xl mx-auto px-4 md:px-8 py-4">
435
+ <div className="flex gap-2 overflow-x-auto pb-2">
436
+ {pkg.sections.map((sec: any, idx: number) => (
437
+ <button
438
+ key={sec.id}
439
+ onClick={() => setCurrentSectionIdx(idx)}
440
+ className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold whitespace-nowrap border-2 transition-colors ${
441
+ idx === currentSectionIdx
442
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] border-[var(--clay-black)]"
443
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-[var(--oat-border)] hover:border-[var(--matcha-400)]"
444
+ }`}
445
+ >
446
+ {sec.title}
447
+ </button>
448
+ ))}
449
+ </div>
450
+ </div>
451
+
452
+ {/* Main Content */}
453
+ <div className="max-w-5xl mx-auto px-4 md:px-8 pb-32">
454
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
455
+ {/* Left: Passage */}
456
+ <div className="lg:col-span-1">
457
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-24">
458
+ <CardContent className="p-5">
459
+ <h2 className="font-headline font-bold text-[var(--clay-black)] mb-3 flex items-center gap-2">
460
+ <MaterialIcon name="menu_book" className="text-[var(--matcha-600)]" />
461
+ Bacaan
462
+ </h2>
463
+ <div className="prose prose-sm max-w-none text-[var(--clay-black)] whitespace-pre-wrap text-sm leading-relaxed max-h-[60vh] overflow-y-auto pr-2">
464
+ {currentSection.questions[0]?.passageText ?? "Tidak ada bacaan untuk section ini."}
465
+ </div>
466
+ </CardContent>
467
+ </Card>
468
+ </div>
469
+
470
+ {/* Right: Questions */}
471
+ <div className="lg:col-span-2 space-y-4">
472
+ {currentSection.questions.map((q: any, idx: number) => {
473
+ const answerValue = answers[q.id] ?? "";
474
+ return (
475
+ <Card
476
+ key={q.id}
477
+ className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]"
478
+ >
479
+ <CardContent className="p-5">
480
+ <div className="flex items-start gap-3 mb-4">
481
+ <span className="w-8 h-8 rounded-full bg-[var(--clay-black)] text-[var(--pure-white)] text-xs flex items-center justify-center font-bold shrink-0">
482
+ {idx + 1}
483
+ </span>
484
+ <div>
485
+ <p className="text-[var(--clay-black)] font-medium leading-relaxed">
486
+ {q.questionText}
487
+ </p>
488
+ <div className="flex gap-2 mt-1">
489
+ <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
490
+ {q.format.replace(/_/g, " ")}
491
+ </span>
492
+ {q.difficulty && (
493
+ <span className="text-xs px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
494
+ Lv.{q.difficulty}
495
+ </span>
496
+ )}
497
+ </div>
498
+ </div>
499
+ </div>
500
+
501
+ <div className="pl-11">
502
+ {submittingQId === q.id && (
503
+ <div className="text-xs text-[var(--matcha-600)] mb-2 flex items-center gap-1">
504
+ <MaterialIcon name="sync" className="text-xs animate-spin" />
505
+ Menyimpan...
506
+ </div>
507
+ )}
508
+ <QuestionInput
509
+ question={q}
510
+ value={answerValue}
511
+ onChange={(val) => {
512
+ if (sectionResultId) {
513
+ onAnswerChange(q.id, sectionResultId, val);
514
+ }
515
+ }}
516
+ disabled={isFinished || !sectionResultId}
517
+ />
518
+ </div>
519
+ </CardContent>
520
+ </Card>
521
+ );
522
+ })}
523
+
524
+ {/* Section Navigation */}
525
+ <div className="flex justify-between pt-4">
526
+ <Button
527
+ variant="outline"
528
+ onClick={() => setCurrentSectionIdx(Math.max(0, currentSectionIdx - 1))}
529
+ disabled={currentSectionIdx === 0}
530
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
531
+ >
532
+ <MaterialIcon name="arrow_back" />
533
+ <span className="ml-2">Sebelumnya</span>
534
+ </Button>
535
+ {currentSectionIdx < pkg.sections.length - 1 ? (
536
+ <Button
537
+ onClick={() => setCurrentSectionIdx(currentSectionIdx + 1)}
538
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]"
539
+ >
540
+ <span className="mr-2">Selanjutnya</span>
541
+ <MaterialIcon name="arrow_forward" />
542
+ </Button>
543
+ ) : (
544
+ <Button
545
+ onClick={onFinish}
546
+ disabled={isFinished}
547
+ className="bg-[var(--pomegranate-500)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-700)] clay-hover rounded-[var(--radius-lg)]"
548
+ >
549
+ <MaterialIcon name="check_circle" />
550
+ <span className="ml-2">Selesaikan</span>
551
+ </Button>
552
+ )}
553
+ </div>
554
+ </div>
555
+ </div>
556
+ </div>
557
+ </div>
558
+ );
559
+ }
apps/web/src/routes/package.$id.tsx CHANGED
@@ -140,10 +140,8 @@ function PackageDetailComponent() {
140
  ) : (
141
  <div className="space-y-3">
142
  {section.questions.map((q: any, idx: number) => (
143
- <Link
144
  key={q.id}
145
- to="/bank/$id"
146
- params={{ id: q.id }}
147
  className="block p-4 rounded-[var(--radius-lg)] bg-[var(--oat-light)] hover:bg-[var(--matcha-300)]/10 transition-colors"
148
  >
149
  <div className="flex items-start gap-3">
@@ -164,7 +162,7 @@ function PackageDetailComponent() {
164
  </div>
165
  </div>
166
  </div>
167
- </Link>
168
  ))}
169
  </div>
170
  )}
@@ -175,10 +173,12 @@ function PackageDetailComponent() {
175
 
176
  {/* Actions */}
177
  <div className="flex gap-3 mt-8">
178
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
179
- <MaterialIcon name="play_arrow" />
180
- <span className="ml-2">Mulai Latihan</span>
181
- </Button>
 
 
182
  {isOwner && (
183
  <Button
184
  variant="outline"
 
140
  ) : (
141
  <div className="space-y-3">
142
  {section.questions.map((q: any, idx: number) => (
143
+ <div
144
  key={q.id}
 
 
145
  className="block p-4 rounded-[var(--radius-lg)] bg-[var(--oat-light)] hover:bg-[var(--matcha-300)]/10 transition-colors"
146
  >
147
  <div className="flex items-start gap-3">
 
162
  </div>
163
  </div>
164
  </div>
165
+ </div>
166
  ))}
167
  </div>
168
  )}
 
173
 
174
  {/* Actions */}
175
  <div className="flex gap-3 mt-8">
176
+ <Link to="/package/$id/take" params={{ id }}>
177
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
178
+ <MaterialIcon name="play_arrow" />
179
+ <span className="ml-2">Mulai Latihan</span>
180
+ </Button>
181
+ </Link>
182
  {isOwner && (
183
  <Button
184
  variant="outline"
apps/web/src/routes/packages.tsx CHANGED
@@ -70,7 +70,7 @@ function PackagesComponent() {
70
  Kumpulan paket latihan dari komunitas.
71
  </p>
72
  </div>
73
- <Link to="/builder">
74
  <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] h-11">
75
  <MaterialIcon name="add" />
76
  <span className="ml-2 hidden sm:inline">Buat Paket</span>
 
70
  Kumpulan paket latihan dari komunitas.
71
  </p>
72
  </div>
73
+ <Link to="/bank">
74
  <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] h-11">
75
  <MaterialIcon name="add" />
76
  <span className="ml-2 hidden sm:inline">Buat Paket</span>
apps/web/src/routes/settings.tsx CHANGED
@@ -37,13 +37,14 @@ function RouteComponent() {
37
  const [baseUrl, setBaseUrl] = useState(storedKey?.baseUrl ?? "https://api.openai.com/v1");
38
  const [apiKey, setApiKey] = useState("");
39
  const [modelName, setModelName] = useState(storedKey?.modelName ?? "gpt-4o-mini");
 
40
  const [isSaving, setIsSaving] = useState(false);
41
 
42
  const handleSave = async () => {
43
  if (!apiKey) return;
44
  setIsSaving(true);
45
  try {
46
- await saveKey({ provider, baseUrl, apiKey, modelName });
47
  setApiKey("");
48
  } finally {
49
  setIsSaving(false);
@@ -114,15 +115,30 @@ function RouteComponent() {
114
  </div>
115
  </div>
116
 
117
- <div className="space-y-2">
118
- <Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
119
- <Input
120
- id="baseUrl"
121
- value={baseUrl}
122
- onChange={(e) => setBaseUrl(e.target.value)}
123
- placeholder="https://api.openai.com/v1"
124
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
125
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  </div>
127
 
128
  <div className="space-y-2">
 
37
  const [baseUrl, setBaseUrl] = useState(storedKey?.baseUrl ?? "https://api.openai.com/v1");
38
  const [apiKey, setApiKey] = useState("");
39
  const [modelName, setModelName] = useState(storedKey?.modelName ?? "gpt-4o-mini");
40
+ const [maxTokens, setMaxTokens] = useState<number>(storedKey?.maxTokens ?? 16384);
41
  const [isSaving, setIsSaving] = useState(false);
42
 
43
  const handleSave = async () => {
44
  if (!apiKey) return;
45
  setIsSaving(true);
46
  try {
47
+ await saveKey({ provider, baseUrl, apiKey, modelName, maxTokens });
48
  setApiKey("");
49
  } finally {
50
  setIsSaving(false);
 
115
  </div>
116
  </div>
117
 
118
+ <div className="grid grid-cols-2 gap-4">
119
+ <div className="space-y-2">
120
+ <Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
121
+ <Input
122
+ id="baseUrl"
123
+ value={baseUrl}
124
+ onChange={(e) => setBaseUrl(e.target.value)}
125
+ placeholder="https://api.openai.com/v1"
126
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
127
+ />
128
+ </div>
129
+ <div className="space-y-2">
130
+ <Label htmlFor="maxTokens" className="text-[var(--clay-black)]">Max Tokens</Label>
131
+ <Input
132
+ id="maxTokens"
133
+ type="number"
134
+ min={1}
135
+ max={65536}
136
+ value={maxTokens}
137
+ onChange={(e) => setMaxTokens(Number(e.target.value))}
138
+ placeholder="16384"
139
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
140
+ />
141
+ </div>
142
  </div>
143
 
144
  <div className="space-y-2">
bun.lock CHANGED
@@ -100,8 +100,11 @@
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": {
@@ -412,6 +415,10 @@
412
 
413
  "@canvas/image-data": ["@canvas/image-data@1.1.0", "", {}, "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA=="],
414
 
 
 
 
 
415
  "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.63.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-jjkmzIRu19uH78AjFInqfcALehbDCZZ7M09hurVawyqNxtOXEg2LR73L59y4QnzfYDEzjbhVzGAd2uDHu0D1aQ=="],
416
 
417
  "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
@@ -542,6 +549,8 @@
542
 
543
  "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="],
544
 
 
 
545
  "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
546
 
547
  "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -574,6 +583,18 @@
574
 
575
  "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.9", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA=="],
576
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  "@mswjs/interceptors": ["@mswjs/interceptors@0.41.6", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q=="],
578
 
579
  "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
@@ -646,6 +667,8 @@
646
 
647
  "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
648
 
 
 
649
  "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
650
 
651
  "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -764,6 +787,8 @@
764
 
765
  "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
766
 
 
 
767
  "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
768
 
769
  "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
@@ -844,6 +869,8 @@
844
 
845
  "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
846
 
 
 
847
  "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
848
 
849
  "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -878,6 +905,8 @@
878
 
879
  "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
880
 
 
 
881
  "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
882
 
883
  "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
@@ -914,6 +943,8 @@
914
 
915
  "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
916
 
 
 
917
  "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
918
 
919
  "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
@@ -952,6 +983,8 @@
952
 
953
  "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
954
 
 
 
955
  "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
956
 
957
  "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -980,6 +1013,8 @@
980
 
981
  "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
982
 
 
 
983
  "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
984
 
985
  "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="],
@@ -1042,6 +1077,8 @@
1042
 
1043
  "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
1044
 
 
 
1045
  "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
1046
 
1047
  "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
@@ -1054,6 +1091,8 @@
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=="],
@@ -1154,6 +1193,8 @@
1154
 
1155
  "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
1156
 
 
 
1157
  "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
1158
 
1159
  "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -1222,7 +1263,7 @@
1222
 
1223
  "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
1224
 
1225
- "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
1226
 
1227
  "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
1228
 
@@ -1274,6 +1315,8 @@
1274
 
1275
  "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
1276
 
 
 
1277
  "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
1278
 
1279
  "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
@@ -1308,14 +1351,22 @@
1308
 
1309
  "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
1310
 
 
 
 
 
1311
  "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
1312
 
1313
  "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
1314
 
 
 
1315
  "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
1316
 
1317
  "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
1318
 
 
 
1319
  "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
1320
 
1321
  "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -1352,6 +1403,10 @@
1352
 
1353
  "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
1354
 
 
 
 
 
1355
  "msw": ["msw@2.13.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g=="],
1356
 
1357
  "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
@@ -1364,10 +1419,14 @@
1364
 
1365
  "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
1366
 
 
 
1367
  "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
1368
 
1369
  "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
1370
 
 
 
1371
  "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
1372
 
1373
  "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1390,6 +1449,8 @@
1390
 
1391
  "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
1392
 
 
 
1393
  "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
1394
 
1395
  "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -1492,10 +1553,16 @@
1492
 
1493
  "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="],
1494
 
 
 
1495
  "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
1496
 
1497
  "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
1498
 
 
 
 
 
1499
  "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
1500
 
1501
  "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
@@ -1550,6 +1617,8 @@
1550
 
1551
  "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
1552
 
 
 
1553
  "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
1554
 
1555
  "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -1618,6 +1687,10 @@
1618
 
1619
  "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
1620
 
 
 
 
 
1621
  "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
1622
 
1623
  "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
@@ -1636,6 +1709,8 @@
1636
 
1637
  "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
1638
 
 
 
1639
  "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="],
1640
 
1641
  "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -1662,6 +1737,8 @@
1662
 
1663
  "terser": ["terser@5.46.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw=="],
1664
 
 
 
1665
  "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
1666
 
1667
  "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
@@ -1684,6 +1761,8 @@
1684
 
1685
  "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
1686
 
 
 
1687
  "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
1688
 
1689
  "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
@@ -1778,6 +1857,10 @@
1778
 
1779
  "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
1780
 
 
 
 
 
1781
  "workbox-background-sync": ["workbox-background-sync@7.4.0", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.0" } }, "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w=="],
1782
 
1783
  "workbox-broadcast-update": ["workbox-broadcast-update@7.4.0", "", { "dependencies": { "workbox-core": "7.4.0" } }, "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA=="],
@@ -1874,6 +1957,8 @@
1874
 
1875
  "@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
1876
 
 
 
1877
  "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],
1878
 
1879
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@@ -1910,10 +1995,14 @@
1910
 
1911
  "eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
1912
 
 
 
1913
  "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
1914
 
1915
  "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
1916
 
 
 
1917
  "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
1918
 
1919
  "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -1942,8 +2031,6 @@
1942
 
1943
  "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
1944
 
1945
- "tempy/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
1946
-
1947
  "tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
1948
 
1949
  "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
@@ -1970,8 +2057,6 @@
1970
 
1971
  "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
1972
 
1973
- "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
1974
-
1975
  "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
1976
 
1977
  "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
@@ -2024,6 +2109,10 @@
2024
 
2025
  "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
2026
 
 
 
 
 
2027
  "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.3", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.3", "@babel/helper-validator-identifier": "^8.0.0-rc.3" } }, "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q=="],
2028
 
2029
  "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -2102,6 +2191,10 @@
2102
 
2103
  "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
2104
 
 
 
 
 
2105
  "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="],
2106
 
2107
  "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
 
100
  "@labas/env": "workspace:*",
101
  "@trpc/client": "catalog:",
102
  "@trpc/server": "catalog:",
103
+ "bullmq": "^5.76.2",
104
  "dotenv": "catalog:",
105
  "drizzle-orm": "^0.45.1",
106
+ "ioredis": "^5.10.1",
107
+ "winston": "^3.19.0",
108
  "zod": "catalog:",
109
  },
110
  "devDependencies": {
 
415
 
416
  "@canvas/image-data": ["@canvas/image-data@1.1.0", "", {}, "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA=="],
417
 
418
+ "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
419
+
420
+ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
421
+
422
  "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.63.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-jjkmzIRu19uH78AjFInqfcALehbDCZZ7M09hurVawyqNxtOXEg2LR73L59y4QnzfYDEzjbhVzGAd2uDHu0D1aQ=="],
423
 
424
  "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
 
549
 
550
  "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="],
551
 
552
+ "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
553
+
554
  "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
555
 
556
  "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
 
583
 
584
  "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.9", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA=="],
585
 
586
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
587
+
588
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
589
+
590
+ "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
591
+
592
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
593
+
594
+ "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
595
+
596
+ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
597
+
598
  "@mswjs/interceptors": ["@mswjs/interceptors@0.41.6", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q=="],
599
 
600
  "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
 
667
 
668
  "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
669
 
670
+ "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
671
+
672
  "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
673
 
674
  "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
 
787
 
788
  "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
789
 
790
+ "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
791
+
792
  "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
793
 
794
  "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
 
869
 
870
  "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
871
 
872
+ "bullmq": ["bullmq@5.76.2", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.4", "tslib": "2.8.1" } }, "sha512-kkNU6TPAjqV3Ep0kIaYhT79Z2IMoA7vadqjmr/zvmPicg0K/cOAecqZTihD726LbI043yPU0MBv/nMQmd5rNIg=="],
873
+
874
  "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
875
 
876
  "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
 
905
 
906
  "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
907
 
908
+ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
909
+
910
  "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
911
 
912
  "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
 
943
 
944
  "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
945
 
946
+ "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
947
+
948
  "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
949
 
950
  "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
 
983
 
984
  "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
985
 
986
+ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
987
+
988
  "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
989
 
990
  "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
 
1013
 
1014
  "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
1015
 
1016
+ "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
1017
+
1018
  "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
1019
 
1020
  "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="],
 
1077
 
1078
  "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
1079
 
1080
+ "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
1081
+
1082
  "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
1083
 
1084
  "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
 
1091
 
1092
  "flag-icons": ["flag-icons@7.5.0", "", {}, "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg=="],
1093
 
1094
+ "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
1095
+
1096
  "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
1097
 
1098
  "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
 
1193
 
1194
  "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
1195
 
1196
+ "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
1197
+
1198
  "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
1199
 
1200
  "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
 
1263
 
1264
  "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
1265
 
1266
+ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
1267
 
1268
  "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
1269
 
 
1315
 
1316
  "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
1317
 
1318
+ "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
1319
+
1320
  "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
1321
 
1322
  "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
 
1351
 
1352
  "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
1353
 
1354
+ "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
1355
+
1356
+ "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
1357
+
1358
  "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
1359
 
1360
  "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
1361
 
1362
+ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
1363
+
1364
  "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
1365
 
1366
  "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
1367
 
1368
+ "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
1369
+
1370
  "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
1371
 
1372
  "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
 
1403
 
1404
  "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
1405
 
1406
+ "msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="],
1407
+
1408
+ "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
1409
+
1410
  "msw": ["msw@2.13.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g=="],
1411
 
1412
  "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
 
1419
 
1420
  "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
1421
 
1422
+ "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
1423
+
1424
  "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
1425
 
1426
  "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
1427
 
1428
+ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
1429
+
1430
  "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
1431
 
1432
  "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
 
1449
 
1450
  "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
1451
 
1452
+ "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
1453
+
1454
  "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
1455
 
1456
  "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
 
1553
 
1554
  "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="],
1555
 
1556
+ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
1557
+
1558
  "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
1559
 
1560
  "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
1561
 
1562
+ "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
1563
+
1564
+ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
1565
+
1566
  "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
1567
 
1568
  "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
 
1617
 
1618
  "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
1619
 
1620
+ "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
1621
+
1622
  "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
1623
 
1624
  "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
 
1687
 
1688
  "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
1689
 
1690
+ "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
1691
+
1692
+ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
1693
+
1694
  "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
1695
 
1696
  "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
 
1709
 
1710
  "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
1711
 
1712
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
1713
+
1714
  "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="],
1715
 
1716
  "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
 
1737
 
1738
  "terser": ["terser@5.46.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw=="],
1739
 
1740
+ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
1741
+
1742
  "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
1743
 
1744
  "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
 
1761
 
1762
  "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
1763
 
1764
+ "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
1765
+
1766
  "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
1767
 
1768
  "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
 
1857
 
1858
  "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
1859
 
1860
+ "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
1861
+
1862
+ "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
1863
+
1864
  "workbox-background-sync": ["workbox-background-sync@7.4.0", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.0" } }, "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w=="],
1865
 
1866
  "workbox-broadcast-update": ["workbox-broadcast-update@7.4.0", "", { "dependencies": { "workbox-core": "7.4.0" } }, "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA=="],
 
1957
 
1958
  "@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
1959
 
1960
+ "@so-ric/colorspace/color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
1961
+
1962
  "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],
1963
 
1964
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
 
1995
 
1996
  "eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
1997
 
1998
+ "execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
1999
+
2000
  "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
2001
 
2002
  "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
2003
 
2004
+ "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
2005
+
2006
  "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
2007
 
2008
  "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
 
2031
 
2032
  "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
2033
 
 
 
2034
  "tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
2035
 
2036
  "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
 
2057
 
2058
  "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
2059
 
 
 
2060
  "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
2061
 
2062
  "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
 
2109
 
2110
  "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
2111
 
2112
+ "@so-ric/colorspace/color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
2113
+
2114
+ "@so-ric/colorspace/color/color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="],
2115
+
2116
  "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.3", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.3", "@babel/helper-validator-identifier": "^8.0.0-rc.3" } }, "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q=="],
2117
 
2118
  "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
 
2191
 
2192
  "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
2193
 
2194
+ "@so-ric/colorspace/color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
2195
+
2196
+ "@so-ric/colorspace/color/color-string/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
2197
+
2198
  "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.3", "", {}, "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA=="],
2199
 
2200
  "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
packages/ai/src/agentic.ts ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { OpenAICompatibleClient } from "./client";
2
+ import { questionSchema, type GenerationInput, type GenerationResult } from "./schemas";
3
+
4
+ interface AgenticStep {
5
+ step: string;
6
+ status: "running" | "done" | "error" | "pending";
7
+ message?: string;
8
+ }
9
+
10
+ export interface AgenticProgress {
11
+ steps: AgenticStep[];
12
+ currentStep: number;
13
+ }
14
+
15
+ function getSystemPrompt(): string {
16
+ return "You are a precise exam question generator. You always return valid JSON. You never include markdown formatting around the JSON.";
17
+ }
18
+
19
+ function getTargetLanguage(examType: string): string {
20
+ if (examType === "JLPT") return "Japanese";
21
+ if (examType === "HSK") return "Chinese";
22
+ if (examType === "GOETHE") return "German";
23
+ return "English";
24
+ }
25
+
26
+ function parseJsonResponse(content: string): unknown {
27
+ if (!content) throw new Error("Empty response from AI");
28
+ let parsed: unknown;
29
+ try {
30
+ parsed = JSON.parse(content);
31
+ } catch {
32
+ const cleaned = content
33
+ .replace(/^```json\s*/, "")
34
+ .replace(/```\s*$/, "")
35
+ .trim();
36
+ parsed = JSON.parse(cleaned);
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ async function step1GeneratePassage(
42
+ client: OpenAICompatibleClient,
43
+ input: GenerationInput,
44
+ ): Promise<{ passage: string; title: string }> {
45
+ const prompt = `Generate an authentic, high-quality reading passage for ${input.examType} ${input.section.toLowerCase()} section at difficulty level ${input.difficulty}/5.
46
+
47
+ Requirements:
48
+ - Language: ${getTargetLanguage(input.examType)}
49
+ - Topics: ${input.topics.join(", ")}
50
+ - Difficulty: ${input.difficulty}/5
51
+ - The passage should be natural, well-structured, and appropriate for the exam level
52
+ - Length should be suitable for ${input.questionCount} comprehension questions
53
+
54
+ Return ONLY valid JSON:
55
+ {
56
+ "title": "Brief title describing the passage topic",
57
+ "passage": "The full reading passage text..."
58
+ }`;
59
+
60
+ const result = await client.chatCompletion({
61
+ model: input.apiKeyConfig.model,
62
+ messages: [
63
+ { role: "system", content: getSystemPrompt() },
64
+ { role: "user", content: prompt },
65
+ ],
66
+ temperature: 0.7,
67
+ max_tokens: input.apiKeyConfig.maxTokens,
68
+ });
69
+
70
+ const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
71
+ if (!parsed.passage || typeof parsed.passage !== "string") {
72
+ throw new Error("Invalid passage generation response");
73
+ }
74
+ return {
75
+ passage: parsed.passage,
76
+ title: typeof parsed.title === "string" ? parsed.title : "Untitled Passage",
77
+ };
78
+ }
79
+
80
+ async function step2ValidatePassage(
81
+ client: OpenAICompatibleClient,
82
+ input: GenerationInput,
83
+ passage: string,
84
+ ): Promise<{ isValid: boolean; feedback: string }> {
85
+ const prompt = `Validate this reading passage for a ${input.examType} exam at difficulty ${input.difficulty}/5.
86
+
87
+ Passage:
88
+ """
89
+ ${passage}
90
+ """
91
+
92
+ Check:
93
+ 1. Grammar and spelling correctness
94
+ 2. Difficulty appropriateness (${input.difficulty}/5)
95
+ 3. Length sufficiency for ${input.questionCount} questions
96
+ 4. Topic relevance: ${input.topics.join(", ")}
97
+ 5. Natural flow and coherence
98
+
99
+ Return ONLY valid JSON:
100
+ {
101
+ "isValid": true/false,
102
+ "feedback": "Brief assessment. If invalid, explain why.",
103
+ "score": number from 1-10
104
+ }`;
105
+
106
+ const result = await client.chatCompletion({
107
+ model: input.apiKeyConfig.model,
108
+ messages: [
109
+ { role: "system", content: getSystemPrompt() },
110
+ { role: "user", content: prompt },
111
+ ],
112
+ temperature: 0.3,
113
+ max_tokens: input.apiKeyConfig.maxTokens,
114
+ });
115
+
116
+ const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
117
+ return {
118
+ isValid: !!parsed.isValid,
119
+ feedback: typeof parsed.feedback === "string" ? parsed.feedback : "No feedback",
120
+ };
121
+ }
122
+
123
+ async function step3GenerateQuestions(
124
+ client: OpenAICompatibleClient,
125
+ input: GenerationInput,
126
+ passage: string,
127
+ ): Promise<Array<Record<string, unknown>>> {
128
+ const formats = input.formats;
129
+ const formatInstructions = formats
130
+ .map((f) => {
131
+ const schemas: Record<string, string> = {
132
+ multiple_choice: `{"format":"multiple_choice","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
133
+ true_false_not_given: `{"format":"true_false_not_given","questionText":"...","correctAnswer":"TRUE|FALSE|NOT_GIVEN","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
134
+ fill_blank: `{"format":"fill_blank","questionText":"...","correctAnswer":"exact text","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
135
+ synonym: `{"format":"synonym","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["vocabulary","synonym"]`,
136
+ grammar_in_context: `{"format":"grammar_in_context","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar"]`,
137
+ sentence_completion: `{"format":"sentence_completion","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
138
+ cloze: `{"format":"cloze","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"serialized mapping","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar","vocabulary"]`,
139
+ reference: `{"format":"reference","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["reference","inference"]`,
140
+ author_view: `{"format":"author_view","questionText":"...","correctAnswer":"YES|NO|NOT_GIVEN","explanation":"...","difficulty":${input.difficulty},"skillTags":["inference","author_view"]`,
141
+ matching_headings: `{"format":"matching_headings","questionText":"Match each paragraph to a heading:","options":[{"key":"i","text":"..."},...],"correctAnswer":"serialized mapping","explanation":"...","difficulty":${input.difficulty},"skillTags":["main_idea","matching"]`,
142
+ kanji_reading: `{"format":"kanji_reading","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["kanji","reading"]`,
143
+ particle_choice: `{"format":"particle_choice","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar","particle"]`,
144
+ article_case: `{"format":"article_case","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar","article","case"]`,
145
+ };
146
+ return schemas[f] || schemas["multiple_choice"];
147
+ })
148
+ .join("\n---\n");
149
+
150
+ const prompt = `Using the following passage, generate ${input.questionCount} reading comprehension questions for ${input.examType} exam.
151
+
152
+ Passage:
153
+ """
154
+ ${passage}
155
+ """
156
+
157
+ Formats to generate:
158
+ ${formatInstructions}
159
+
160
+ Rules:
161
+ - Each question must be directly answerable from the passage
162
+ - Use "passageText" field with the relevant excerpt from the passage (or full passage if needed)
163
+ - Questions should test real comprehension, not surface recall
164
+ - For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
165
+ - Options must be plausible distractors
166
+
167
+ Return ONLY valid JSON:
168
+ {
169
+ "questions": [
170
+ // array of question objects matching the format schemas above
171
+ ]
172
+ }`;
173
+
174
+ const result = await client.chatCompletion({
175
+ model: input.apiKeyConfig.model,
176
+ messages: [
177
+ { role: "system", content: getSystemPrompt() },
178
+ { role: "user", content: prompt },
179
+ ],
180
+ temperature: 0.7,
181
+ max_tokens: input.apiKeyConfig.maxTokens,
182
+ });
183
+
184
+ const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
185
+ if (!Array.isArray(parsed.questions)) {
186
+ throw new Error("Missing questions array in AI response");
187
+ }
188
+ return parsed.questions as Array<Record<string, unknown>>;
189
+ }
190
+
191
+ async function step4SelfValidate(
192
+ client: OpenAICompatibleClient,
193
+ input: GenerationInput,
194
+ passage: string,
195
+ questions: Array<Record<string, unknown>>,
196
+ ): Promise<{ correctedQuestions: Array<Record<string, unknown>>; confidence: number }> {
197
+ const qaPairs = questions
198
+ .map((q, i) => `Q${i + 1}: ${q.questionText}\nA: ${q.correctAnswer}`)
199
+ .join("\n\n");
200
+
201
+ const prompt = `You are a strict exam validator. Review these questions against the passage and identify any errors.
202
+
203
+ Passage:
204
+ """
205
+ ${passage}
206
+ """
207
+
208
+ Questions & Claimed Answers:
209
+ ${qaPairs}
210
+
211
+ For each question, verify:
212
+ 1. Is the claimed answer truly correct based on the passage?
213
+ 2. Are there any ambiguous questions?
214
+ 3. Are distractors plausible but clearly wrong?
215
+
216
+ Return ONLY valid JSON:
217
+ {
218
+ "overallConfidence": number from 0-100,
219
+ "issues": [
220
+ {
221
+ "questionIndex": 0-based index,
222
+ "issue": "description of problem",
223
+ "suggestedFix": "corrected answer or explanation"
224
+ }
225
+ ],
226
+ "needsRevision": true/false
227
+ }`;
228
+
229
+ const result = await client.chatCompletion({
230
+ model: input.apiKeyConfig.model,
231
+ messages: [
232
+ { role: "system", content: getSystemPrompt() },
233
+ { role: "user", content: prompt },
234
+ ],
235
+ temperature: 0.3,
236
+ max_tokens: input.apiKeyConfig.maxTokens,
237
+ });
238
+
239
+ const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
240
+ const confidence = typeof parsed.overallConfidence === "number" ? parsed.overallConfidence : 75;
241
+ const issues = Array.isArray(parsed.issues) ? parsed.issues : [];
242
+
243
+ // Apply fixes if needed
244
+ const corrected = questions.map((q, i) => {
245
+ const issue = issues.find((iss: any) => iss?.questionIndex === i);
246
+ if (issue && issue.suggestedFix) {
247
+ return { ...q, explanation: `${q.explanation}\n[Validator note: ${issue.suggestedFix}]` };
248
+ }
249
+ return q;
250
+ });
251
+
252
+ return { correctedQuestions: corrected, confidence };
253
+ }
254
+
255
+ export async function generateQuestionsAgentic(
256
+ input: GenerationInput,
257
+ onProgress?: (progress: AgenticProgress) => void,
258
+ ): Promise<GenerationResult> {
259
+ const start = Date.now();
260
+ const client = new OpenAICompatibleClient(
261
+ input.apiKeyConfig.baseUrl,
262
+ input.apiKeyConfig.apiKey,
263
+ );
264
+
265
+ const steps: [AgenticStep, AgenticStep, AgenticStep, AgenticStep] = [
266
+ { step: "generate_passage", status: "running" },
267
+ { step: "validate_passage", status: "pending" as any },
268
+ { step: "generate_questions", status: "pending" as any },
269
+ { step: "self_validate", status: "pending" as any },
270
+ ];
271
+
272
+ const report = (current: number) => {
273
+ if (onProgress) {
274
+ onProgress({ steps, currentStep: current });
275
+ }
276
+ };
277
+
278
+ // Step 1: Generate passage
279
+ report(0);
280
+ const { passage, title } = await step1GeneratePassage(client, input);
281
+ steps[0].status = "done";
282
+ steps[0].message = `Generated: ${title}`;
283
+
284
+ // Step 2: Validate passage
285
+ steps[1].status = "running";
286
+ report(1);
287
+ const validation = await step2ValidatePassage(client, input, passage);
288
+ steps[1].status = validation.isValid ? "done" : "error";
289
+ steps[1].message = validation.feedback;
290
+
291
+ // Even if not perfect, continue (the feedback is logged)
292
+
293
+ // Step 3: Generate questions
294
+ steps[2].status = "running";
295
+ report(2);
296
+ const rawQuestions = await step3GenerateQuestions(client, input, passage);
297
+ steps[2].status = "done";
298
+ steps[2].message = `Generated ${rawQuestions.length} questions`;
299
+
300
+ // Step 4: Self-validate
301
+ steps[3].status = "running";
302
+ report(3);
303
+ const { correctedQuestions, confidence } = await step4SelfValidate(
304
+ client,
305
+ input,
306
+ passage,
307
+ rawQuestions,
308
+ );
309
+ steps[3].status = "done";
310
+ steps[3].message = `Confidence score: ${confidence}%`;
311
+
312
+ // Parse and validate final questions
313
+ const questions = correctedQuestions
314
+ .map((q) => {
315
+ try {
316
+ return questionSchema.parse({ ...q, passageText: passage });
317
+ } catch (e) {
318
+ console.warn("Question validation failed:", e);
319
+ return null;
320
+ }
321
+ })
322
+ .filter((q): q is NonNullable<typeof q> => q !== null);
323
+
324
+ if (questions.length === 0) {
325
+ throw new Error("No valid questions generated after agentic validation");
326
+ }
327
+
328
+ return {
329
+ questions,
330
+ meta: {
331
+ model: input.apiKeyConfig.model,
332
+ durationMs: Date.now() - start,
333
+ mode: "agentic",
334
+ },
335
+ };
336
+ }
packages/ai/src/client.ts CHANGED
@@ -11,35 +11,121 @@ export interface ChatCompletionOptions {
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: {
@@ -47,13 +133,68 @@ export class OpenAICompatibleClient {
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
  }
 
11
  response_format?: { type: "json_object" };
12
  }
13
 
14
+ export interface StreamCallbacks {
15
+ onToken: (token: string) => void;
16
+ }
17
+
18
+ export interface ChatCompletionResult {
19
+ content: string;
20
  usage?: {
21
+ prompt_tokens?: number;
22
+ completion_tokens?: number;
23
+ total_tokens?: number;
24
  };
25
  }
26
 
27
+ // Simple debug logger that works in both Node and Bun
28
+ function log(
29
+ level: "info" | "error" | "warn",
30
+ message: string,
31
+ meta?: Record<string, unknown>,
32
+ ) {
33
+ const timestamp = new Date().toISOString();
34
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
35
+ // eslint-disable-next-line no-console
36
+ console[level](`[${timestamp}] [AI-CLIENT] ${level.toUpperCase()}: ${message}${metaStr}`);
37
+ }
38
+
39
+ function parseSSELine(line: string): { content?: string; usage?: ChatCompletionResult["usage"] } | null {
40
+ if (!line.startsWith("data: ")) return null;
41
+ const data = line.slice(6).trim();
42
+ if (data === "[DONE]") return null;
43
+ try {
44
+ const chunk = JSON.parse(data);
45
+ const content = chunk.choices?.[0]?.delta?.content;
46
+ const usage = chunk.usage;
47
+ return { content: typeof content === "string" ? content : undefined, usage };
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ async function readSSEStream(
54
+ reader: any,
55
+ callbacks: StreamCallbacks,
56
+ ): Promise<{ content: string; usage?: ChatCompletionResult["usage"] }> {
57
+ const decoder = new TextDecoder();
58
+ let buffer = "";
59
+ let fullContent = "";
60
+ let lastUsage: ChatCompletionResult["usage"] | undefined;
61
+
62
+ while (true) {
63
+ const { done, value } = await reader.read();
64
+ if (done) break;
65
+
66
+ buffer += decoder.decode(value, { stream: true });
67
+ const lines = buffer.split("\n");
68
+ buffer = lines.pop() ?? "";
69
+
70
+ for (const line of lines) {
71
+ const parsed = parseSSELine(line);
72
+ if (!parsed) continue;
73
+ if (parsed.content) {
74
+ fullContent += parsed.content;
75
+ callbacks.onToken(parsed.content);
76
+ }
77
+ if (parsed.usage) {
78
+ lastUsage = parsed.usage;
79
+ }
80
+ }
81
+ }
82
+
83
+ // Process any remaining buffer
84
+ if (buffer.trim()) {
85
+ const parsed = parseSSELine(buffer.trim());
86
+ if (parsed) {
87
+ if (parsed.content) {
88
+ fullContent += parsed.content;
89
+ callbacks.onToken(parsed.content);
90
+ }
91
+ if (parsed.usage) {
92
+ lastUsage = parsed.usage;
93
+ }
94
+ }
95
+ }
96
+
97
+ return { content: fullContent, usage: lastUsage };
98
+ }
99
+
100
  export class OpenAICompatibleClient {
101
  constructor(
102
  private baseUrl: string,
103
  private apiKey: string,
104
  ) {}
105
 
106
+ async chatCompletion(
107
+ opts: ChatCompletionOptions,
108
+ callbacks?: StreamCallbacks,
109
+ ): Promise<ChatCompletionResult> {
110
  const url = `${this.baseUrl.replace(/\/$/, "")}/chat/completions`;
111
+ const stream = true;
112
  const body = {
113
  model: opts.model,
114
  messages: opts.messages,
115
  temperature: opts.temperature ?? 0.7,
116
+ stream,
117
+ ...(opts.max_tokens ? { max_tokens: opts.max_tokens } : {}),
118
  ...(opts.response_format ? { response_format: opts.response_format } : {}),
119
  };
120
 
121
+ log("info", "Sending chat completion request", {
122
+ url,
123
+ model: opts.model,
124
+ messageCount: opts.messages.length,
125
+ maxTokens: opts.max_tokens,
126
+ stream,
127
+ });
128
+
129
  const res = await fetch(url, {
130
  method: "POST",
131
  headers: {
 
133
  Authorization: `Bearer ${this.apiKey}`,
134
  },
135
  body: JSON.stringify(body),
136
+ signal: AbortSignal.timeout(300_000),
137
+ });
138
+
139
+ log("info", "Received response", {
140
+ status: res.status,
141
+ statusText: res.statusText,
142
+ contentType: res.headers.get("content-type"),
143
  });
144
 
145
  if (!res.ok) {
146
  const text = await res.text();
147
+ const preview = text.slice(0, 500);
148
+ log("error", "API request failed", {
149
+ status: res.status,
150
+ statusText: res.statusText,
151
+ preview,
152
+ isHtml: preview.trim().startsWith("<"),
153
+ });
154
+
155
+ if (preview.trim().startsWith("<")) {
156
+ throw new Error(
157
+ `Provider returned HTML instead of JSON (status ${res.status}). ` +
158
+ `This usually means the base URL or endpoint is wrong, or the provider does not support this API. ` +
159
+ `Preview: ${preview.slice(0, 200)}`,
160
+ );
161
+ }
162
+
163
+ throw new Error(`OpenAI-compatible API error ${res.status}: ${preview}`);
164
  }
165
 
166
+ if (!res.body) {
167
+ throw new Error("Empty response body from API");
168
+ }
169
+
170
+ const reader = res.body.getReader() as any;
171
+ const result = await readSSEStream(
172
+ reader,
173
+ callbacks ?? { onToken: () => {} },
174
+ );
175
+
176
+ // Defense: if content looks like HTML, something went wrong with streaming
177
+ if (result.content.trim().startsWith("<")) {
178
+ const preview = result.content.slice(0, 500);
179
+ log("error", "Stream returned HTML instead of JSON", {
180
+ preview: preview.slice(0, 200),
181
+ });
182
+ throw new Error(
183
+ `Provider returned HTML in stream instead of JSON. ` +
184
+ `The provider may not support SSE streaming. ` +
185
+ `Preview: ${preview.slice(0, 200)}`,
186
+ );
187
+ }
188
+
189
+ if (!result.content) {
190
+ throw new Error("Empty response from AI");
191
+ }
192
+
193
+ log("info", "Chat completion successful", {
194
+ contentLength: result.content.length,
195
+ usage: result.usage,
196
+ });
197
+
198
+ return result;
199
  }
200
  }
packages/ai/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
  export { OpenAICompatibleClient } from "./client";
2
  export { generateQuestionsQuick } from "./pipeline";
 
 
3
  export { buildQuickModePrompt } from "./prompts";
4
  export * from "./schemas";
 
1
  export { OpenAICompatibleClient } from "./client";
2
  export { generateQuestionsQuick } from "./pipeline";
3
+ export { generateQuestionsAgentic } from "./agentic";
4
+ export type { AgenticProgress } from "./agentic";
5
  export { buildQuickModePrompt } from "./prompts";
6
  export * from "./schemas";
packages/ai/src/pipeline.ts CHANGED
@@ -6,47 +6,89 @@ import {
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") {
@@ -55,29 +97,42 @@ export async function generateQuestionsQuick(
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
  },
 
6
  type GenerationResult,
7
  } from "./schemas";
8
 
9
+ export interface QuickModeCallbacks {
10
+ onToken?: (token: string) => void;
11
+ }
12
+
13
+ function log(level: "info" | "error" | "warn", message: string, meta?: Record<string, unknown>) {
14
+ const timestamp = new Date().toISOString();
15
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
16
+ // eslint-disable-next-line no-console
17
+ console[level](`[${timestamp}] [PIPELINE] ${level.toUpperCase()}: ${message}${metaStr}`);
18
+ }
19
+
20
  export async function generateQuestionsQuick(
21
  input: GenerationInput,
22
+ callbacks?: QuickModeCallbacks,
23
  ): Promise<GenerationResult> {
24
  const start = Date.now();
25
+ log("info", "Quick mode generation started", {
26
+ model: input.apiKeyConfig.model,
27
+ baseUrl: input.apiKeyConfig.baseUrl,
28
+ examType: input.examType,
29
+ section: input.section,
30
+ questionCount: input.questionCount,
31
+ formats: input.formats,
32
+ maxTokens: input.apiKeyConfig.maxTokens,
33
+ });
34
+
35
  const client = new OpenAICompatibleClient(
36
  input.apiKeyConfig.baseUrl,
37
  input.apiKeyConfig.apiKey,
38
  );
39
 
40
  const prompt = buildQuickModePrompt(input);
41
+ log("info", "Prompt built", { promptLength: prompt.length });
42
 
43
+ let result;
44
+ try {
45
+ result = await client.chatCompletion(
46
  {
47
+ model: input.apiKeyConfig.model,
48
+ messages: [
49
+ {
50
+ role: "system",
51
+ content:
52
+ "You are a precise exam question generator. You always return valid JSON. You never include markdown formatting around the JSON.",
53
+ },
54
+ { role: "user", content: prompt },
55
+ ],
56
+ temperature: 0.7,
57
+ max_tokens: input.apiKeyConfig.maxTokens,
58
  },
59
+ callbacks?.onToken
60
+ ? { onToken: callbacks.onToken }
61
+ : undefined,
62
+ );
63
+ } catch (err: any) {
64
+ log("error", "chatCompletion failed in quick mode", { error: err.message });
65
+ throw err;
 
 
 
66
  }
67
 
68
+ const content = result.content;
69
+ log("info", "Raw response received", { contentLength: content.length, preview: content.slice(0, 200) });
70
+
71
  let parsed: unknown;
72
  try {
73
  parsed = JSON.parse(content);
74
+ log("info", "JSON parsed successfully (direct)");
75
+ } catch (parseErr: any) {
76
+ log("warn", "Direct JSON parse failed, trying markdown strip", { error: parseErr.message });
77
  const cleaned = content
78
  .replace(/^```json\s*/, "")
79
  .replace(/```\s*$/, "")
80
  .trim();
81
+ try {
82
+ parsed = JSON.parse(cleaned);
83
+ log("info", "JSON parsed successfully after markdown strip");
84
+ } catch (stripErr: any) {
85
+ log("error", "JSON parse failed even after markdown strip", {
86
+ error: stripErr.message,
87
+ cleanedPreview: cleaned.slice(0, 500),
88
+ originalPreview: content.slice(0, 500),
89
+ });
90
+ throw new Error(`Failed to parse AI response as JSON: ${stripErr.message}. Preview: ${content.slice(0, 200)}`);
91
+ }
92
  }
93
 
94
  if (!parsed || typeof parsed !== "object") {
 
97
 
98
  const raw = parsed as Record<string, unknown>;
99
  if (!Array.isArray(raw.questions)) {
100
+ log("error", "Missing questions array in parsed JSON", { keys: Object.keys(raw) });
101
  throw new Error("Missing 'questions' array in AI response");
102
  }
103
 
104
+ log("info", "Validating questions", { rawCount: raw.questions.length });
105
+
106
  const questions = raw.questions
107
+ .map((q: unknown, idx: number) => {
108
  try {
109
  return questionSchema.parse(q);
110
+ } catch (e: any) {
111
+ log("warn", `Question ${idx} validation failed`, {
112
+ error: e.message,
113
+ questionPreview: JSON.stringify(q).slice(0, 200),
114
+ });
115
  return null;
116
  }
117
  })
118
  .filter((q): q is NonNullable<typeof q> => q !== null);
119
 
120
  if (questions.length === 0) {
121
+ log("error", "No valid questions after validation", { rawCount: raw.questions.length });
122
  throw new Error("No valid questions generated");
123
  }
124
 
125
+ log("info", "Quick mode generation completed", {
126
+ validCount: questions.length,
127
+ rawCount: raw.questions.length,
128
+ durationMs: Date.now() - start,
129
+ });
130
+
131
  return {
132
  questions,
133
  meta: {
134
  model: input.apiKeyConfig.model,
135
+ tokensUsed: result.usage?.total_tokens,
136
  durationMs: Date.now() - start,
137
  mode: "quick",
138
  },
packages/ai/src/prompts.ts CHANGED
@@ -152,8 +152,8 @@ INSTRUCTIONS:
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.
 
152
  - Each question must have:
153
  * a reading passage (passageText)
154
  * a clear question prompt (questionText)
155
+ * a correct answer (correctAnswer) - dijelaskan dengan bahasa Indonesia
156
+ * an explanation (explanation) - dijelaskan dengan bahasa Indonesia
157
  * difficulty level (${difficulty})
158
  * relevant skill tags (skillTags)
159
  - Questions should test real comprehension, not just surface-level recall.
packages/ai/src/schemas.ts CHANGED
@@ -29,6 +29,7 @@ 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 ────────────────────────────────
 
29
  baseUrl: z.string().url(),
30
  apiKey: z.string().min(1),
31
  model: z.string().min(1),
32
+ maxTokens: z.number().int().min(1).max(65536).default(16384),
33
  });
34
 
35
  // ── Question Option Schemas ────────────────────────────────
packages/api/package.json CHANGED
@@ -17,8 +17,11 @@
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": {
 
17
  "@labas/env": "workspace:*",
18
  "@trpc/client": "catalog:",
19
  "@trpc/server": "catalog:",
20
+ "bullmq": "^5.76.2",
21
  "dotenv": "catalog:",
22
  "drizzle-orm": "^0.45.1",
23
+ "ioredis": "^5.10.1",
24
+ "winston": "^3.19.0",
25
  "zod": "catalog:"
26
  },
27
  "devDependencies": {
packages/api/src/logger.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import winston from "winston";
2
+
3
+ const { combine, timestamp, printf, colorize, errors } = winston.format;
4
+
5
+ const customFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
6
+ let msg = `${timestamp} [${level}]: ${message}`;
7
+ if (Object.keys(metadata).length > 0) {
8
+ msg += ` \n ${JSON.stringify(metadata, null, 2)}`;
9
+ }
10
+ if (stack) {
11
+ msg += `\n ${stack}`;
12
+ }
13
+ return msg;
14
+ });
15
+
16
+ export const logger = winston.createLogger({
17
+ level: process.env.LOG_LEVEL ?? "info",
18
+ defaultMeta: { service: "labas-api" },
19
+ transports: [
20
+ new winston.transports.Console({
21
+ format: combine(
22
+ colorize(),
23
+ timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
24
+ errors({ stack: true }),
25
+ customFormat,
26
+ ),
27
+ }),
28
+ ],
29
+ });
packages/api/src/queue.ts ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Queue, Worker, type Job } from "bullmq";
2
+ import IORedis from "ioredis";
3
+ import { env } from "@labas/env/server";
4
+ import { generateQuestionsQuick, generateQuestionsAgentic, type GenerationInput } from "@labas/ai";
5
+ import { db } from "@labas/db";
6
+ import { generationJob, question, testPackage, packageSection, sectionQuestion } from "@labas/db";
7
+ import { eq } from "drizzle-orm";
8
+
9
+ const redisConnection = new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null });
10
+
11
+ export const generationQueue = new Queue("generation", {
12
+ connection: redisConnection,
13
+ });
14
+
15
+ export const generationWorker = new Worker(
16
+ "generation",
17
+ async (job: Job<{ input: GenerationInput; jobId: string }>) => {
18
+ const { input, jobId } = job.data;
19
+ const start = Date.now();
20
+
21
+ const updateProgress = async (progress: number, message: string) => {
22
+ await job.updateProgress(progress);
23
+ await db
24
+ .update(generationJob)
25
+ .set({ progress, progressMessage: message })
26
+ .where(eq(generationJob.id, jobId));
27
+ };
28
+
29
+ // Heartbeat: update DB every 10s so BullMQ knows worker is alive
30
+ let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
31
+ const startHeartbeat = () => {
32
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
33
+ heartbeatInterval = setInterval(() => {
34
+ db
35
+ .update(generationJob)
36
+ .set({ updatedAt: new Date() })
37
+ .where(eq(generationJob.id, jobId))
38
+ .catch(() => {});
39
+ }, 10_000);
40
+ };
41
+ const stopHeartbeat = () => {
42
+ if (heartbeatInterval) {
43
+ clearInterval(heartbeatInterval);
44
+ heartbeatInterval = null;
45
+ }
46
+ };
47
+
48
+ try {
49
+ await db
50
+ .update(generationJob)
51
+ .set({ status: "running" })
52
+ .where(eq(generationJob.id, jobId));
53
+
54
+ if (input.mode === "agentic") {
55
+ await updateProgress(10, "Generating passage...");
56
+ }
57
+
58
+ startHeartbeat();
59
+
60
+ let approxTokens = 0;
61
+ const countToken = (token: string) => {
62
+ approxTokens += Math.ceil(token.length / 4);
63
+ };
64
+
65
+ const result =
66
+ input.mode === "agentic"
67
+ ? await generateQuestionsAgentic(input, async (p) => {
68
+ const stepProgress = Math.min(
69
+ 10 + Math.round((p.currentStep / p.steps.length) * 80),
70
+ 90,
71
+ );
72
+ const msg = p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
73
+ await updateProgress(stepProgress, msg);
74
+ })
75
+ : await generateQuestionsQuick(input, {
76
+ onToken: (token) => {
77
+ countToken(token);
78
+ // Update progress message with token count every ~500 chars
79
+ if (approxTokens % 20 === 0) {
80
+ job.updateProgress(job.progress ?? 5).catch(() => {});
81
+ db
82
+ .update(generationJob)
83
+ .set({ progressMessage: `Generating... (~${approxTokens} tokens)` })
84
+ .where(eq(generationJob.id, jobId))
85
+ .catch(() => {});
86
+ }
87
+ },
88
+ });
89
+
90
+ stopHeartbeat();
91
+ await updateProgress(95, "Saving to bank...");
92
+
93
+ // Idempotent auto-save: check if already saved
94
+ const [existingJob] = await db
95
+ .select({ resultJson: generationJob.resultJson })
96
+ .from(generationJob)
97
+ .where(eq(generationJob.id, jobId))
98
+ .limit(1);
99
+
100
+ const alreadySaved =
101
+ existingJob?.resultJson &&
102
+ typeof existingJob.resultJson === "object" &&
103
+ "savedQuestionIds" in (existingJob.resultJson as any) &&
104
+ Array.isArray((existingJob.resultJson as any).savedQuestionIds) &&
105
+ (existingJob.resultJson as any).savedQuestionIds.length > 0;
106
+
107
+ let savedQuestionIds: string[] = [];
108
+ let generatedPackageId: string | null = null;
109
+
110
+ if (!alreadySaved) {
111
+ const [jobRow] = await db
112
+ .select({ userId: generationJob.userId })
113
+ .from(generationJob)
114
+ .where(eq(generationJob.id, jobId))
115
+ .limit(1);
116
+
117
+ const userId = jobRow?.userId;
118
+ if (userId) {
119
+ const toInsert = result.questions.map((q) => ({
120
+ examTypeId: input.examType,
121
+ sectionTypeId: input.section,
122
+ format: q.format,
123
+ passageText: q.passageText,
124
+ questionText: q.questionText,
125
+ options: (q as any).options ?? null,
126
+ correctAnswer: q.correctAnswer,
127
+ explanation: q.explanation,
128
+ difficulty: q.difficulty,
129
+ skillTags: q.skillTags,
130
+ source: "ai" as const,
131
+ aiModel: input.apiKeyConfig.model,
132
+ creatorUserId: userId,
133
+ isPublic: false,
134
+ }));
135
+
136
+ const inserted = await db
137
+ .insert(question)
138
+ .values(toInsert as any)
139
+ .returning({ id: question.id });
140
+
141
+ savedQuestionIds = inserted.map((r) => r.id);
142
+
143
+ // Auto-create a package from generated questions
144
+ if (savedQuestionIds.length > 0) {
145
+ const dateStr = new Date().toLocaleDateString("id-ID", {
146
+ day: "numeric",
147
+ month: "short",
148
+ year: "numeric",
149
+ });
150
+ const pkgTitle = `AI Generated β€” ${input.examType} ${input.section} β€” ${dateStr}`;
151
+
152
+ const [pkg] = await db
153
+ .insert(testPackage)
154
+ .values({
155
+ title: pkgTitle,
156
+ description: `Paket latihan AI-generated dengan ${savedQuestionIds.length} soal ${input.examType} ${input.section}.`,
157
+ examTypeId: input.examType,
158
+ creatorUserId: userId,
159
+ isPublic: false,
160
+ totalQuestions: savedQuestionIds.length,
161
+ totalSections: 1,
162
+ estimatedDurationMin: Math.ceil(savedQuestionIds.length * 1.5),
163
+ })
164
+ .returning();
165
+
166
+ if (pkg) {
167
+ generatedPackageId = pkg.id;
168
+
169
+ const [sec] = await db
170
+ .insert(packageSection)
171
+ .values({
172
+ packageId: pkg.id,
173
+ sectionTypeId: input.section,
174
+ title: `${input.section} Section`,
175
+ orderIndex: 0,
176
+ })
177
+ .returning();
178
+
179
+ if (sec) {
180
+ await db.insert(sectionQuestion).values(
181
+ savedQuestionIds.map((qid, idx) => ({
182
+ sectionId: sec.id,
183
+ questionId: qid,
184
+ orderIndex: idx,
185
+ })),
186
+ );
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ await updateProgress(100, "Completed");
194
+
195
+ await db
196
+ .update(generationJob)
197
+ .set({
198
+ status: "completed",
199
+ resultJson: { ...result, savedQuestionIds, generatedPackageId } as any,
200
+ tokensUsed: result.meta.tokensUsed ?? approxTokens,
201
+ durationMs: Date.now() - start,
202
+ completedAt: new Date(),
203
+ })
204
+ .where(eq(generationJob.id, jobId));
205
+ } catch (err: any) {
206
+ stopHeartbeat();
207
+ await db
208
+ .update(generationJob)
209
+ .set({
210
+ status: "failed",
211
+ errorMessage: err.message ?? String(err),
212
+ durationMs: Date.now() - start,
213
+ completedAt: new Date(),
214
+ })
215
+ .where(eq(generationJob.id, jobId));
216
+ throw err;
217
+ }
218
+ },
219
+ {
220
+ connection: redisConnection,
221
+ concurrency: 2,
222
+ },
223
+ );
224
+
225
+ export async function enqueueGeneration(
226
+ userId: string,
227
+ input: GenerationInput,
228
+ ): Promise<string> {
229
+ const [jobRecord] = await db
230
+ .insert(generationJob)
231
+ .values({
232
+ userId,
233
+ mode: input.mode,
234
+ examTypeId: input.examType,
235
+ sectionTypeId: input.section,
236
+ questionCount: input.questionCount,
237
+ status: "pending",
238
+ progress: 0,
239
+ })
240
+ .returning();
241
+
242
+ if (!jobRecord) {
243
+ throw new Error("Failed to create generation job");
244
+ }
245
+
246
+ await generationQueue.add(
247
+ "generate",
248
+ { input, jobId: jobRecord.id },
249
+ {
250
+ jobId: jobRecord.id,
251
+ removeOnComplete: { count: 100 },
252
+ removeOnFail: { count: 100 },
253
+ attempts: 3,
254
+ backoff: {
255
+ type: "exponential",
256
+ delay: 5000,
257
+ },
258
+ },
259
+ );
260
+
261
+ return jobRecord.id;
262
+ }
packages/api/src/routers/ai.ts CHANGED
@@ -1,16 +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
 
1
  import { z } from "zod";
2
+ import { eq, desc } from "drizzle-orm";
3
  import { router, protectedProcedure } from "../index";
 
4
  import { generationInputSchema } from "@labas/ai/schemas";
5
+ import { enqueueGeneration } from "../queue";
6
  import { db } from "@labas/db";
7
+ import { question, generationJob } from "@labas/db";
8
 
9
  export const aiRouter = router({
10
  generate: protectedProcedure
11
  .input(generationInputSchema)
12
+ .mutation(async ({ ctx, input }) => {
13
+ const jobId = await enqueueGeneration(ctx.session.user.id, input);
14
+ return { jobId };
15
+ }),
16
+
17
+ getJobStatus: protectedProcedure
18
+ .input(z.object({ jobId: z.string().uuid() }))
19
+ .query(async ({ ctx, input }) => {
20
+ const [job] = await db
21
+ .select()
22
+ .from(generationJob)
23
+ .where(eq(generationJob.id, input.jobId))
24
+ .limit(1);
25
+
26
+ if (!job) return null;
27
+ if (job.userId !== ctx.session.user.id) return null;
28
+
29
+ return job;
30
+ }),
31
+
32
+ myJobs: protectedProcedure
33
+ .input(
34
+ z.object({
35
+ limit: z.number().min(1).max(50).default(20),
36
+ offset: z.number().min(0).default(0),
37
+ }).optional(),
38
+ )
39
+ .query(async ({ ctx, input }) => {
40
+ const rows = await db
41
+ .select()
42
+ .from(generationJob)
43
+ .where(eq(generationJob.userId, ctx.session.user.id))
44
+ .orderBy(desc(generationJob.createdAt))
45
+ .limit(input?.limit ?? 20)
46
+ .offset(input?.offset ?? 0);
47
+ return rows;
48
  }),
49
 
50
  saveQuestions: protectedProcedure
packages/api/src/routers/attempt.ts ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { eq, and, desc, sql, inArray } from "drizzle-orm";
3
+ import { router, protectedProcedure } from "../index";
4
+ import { db } from "@labas/db";
5
+ import {
6
+ testAttempt,
7
+ sectionResult,
8
+ answer,
9
+ testPackage,
10
+ packageSection,
11
+ sectionQuestion,
12
+ question,
13
+ } from "@labas/db";
14
+
15
+ function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string): boolean {
16
+ const ua = userAnswer.trim();
17
+ const ca = correctAnswer.trim();
18
+
19
+ switch (format) {
20
+ case "true_false_not_given":
21
+ case "author_view":
22
+ return ua.toUpperCase() === ca.toUpperCase();
23
+ case "fill_blank":
24
+ return ua.toLowerCase() === ca.toLowerCase();
25
+ case "multiple_choice":
26
+ case "synonym":
27
+ case "grammar_in_context":
28
+ case "sentence_completion":
29
+ case "summary_completion":
30
+ case "cloze":
31
+ case "reference":
32
+ case "kanji_reading":
33
+ case "particle_choice":
34
+ case "article_case":
35
+ case "matching_headings":
36
+ case "matching_information":
37
+ return ua.toUpperCase() === ca.toUpperCase();
38
+ default:
39
+ return ua === ca;
40
+ }
41
+ }
42
+
43
+ export const attemptRouter = router({
44
+ start: protectedProcedure
45
+ .input(z.object({ packageId: z.string().uuid() }))
46
+ .mutation(async ({ ctx, input }) => {
47
+ const userId = ctx.session.user.id;
48
+
49
+ const [pkg] = await db
50
+ .select()
51
+ .from(testPackage)
52
+ .where(eq(testPackage.id, input.packageId))
53
+ .limit(1);
54
+
55
+ if (!pkg) throw new Error("Package not found");
56
+ if (!pkg.isPublic && pkg.creatorUserId !== userId) {
57
+ throw new Error("Not authorized to access this package");
58
+ }
59
+
60
+ const sections = await db
61
+ .select({
62
+ id: packageSection.id,
63
+ sectionTypeId: packageSection.sectionTypeId,
64
+ })
65
+ .from(packageSection)
66
+ .where(eq(packageSection.packageId, input.packageId))
67
+ .orderBy(packageSection.orderIndex);
68
+
69
+ const [attempt] = await db
70
+ .insert(testAttempt)
71
+ .values({
72
+ userId,
73
+ packageId: input.packageId,
74
+ status: "in_progress",
75
+ })
76
+ .returning();
77
+
78
+ if (!attempt) throw new Error("Failed to create attempt");
79
+
80
+ for (const section of sections) {
81
+ await db.insert(sectionResult).values({
82
+ attemptId: attempt.id,
83
+ sectionTypeId: section.sectionTypeId,
84
+ });
85
+ }
86
+
87
+ await db
88
+ .update(testPackage)
89
+ .set({ usageCount: sql`${testPackage.usageCount} + 1` })
90
+ .where(eq(testPackage.id, input.packageId));
91
+
92
+ return { attemptId: attempt.id };
93
+ }),
94
+
95
+ getById: protectedProcedure
96
+ .input(z.object({ id: z.string().uuid() }))
97
+ .query(async ({ ctx, input }) => {
98
+ const userId = ctx.session.user.id;
99
+
100
+ const [attempt] = await db
101
+ .select()
102
+ .from(testAttempt)
103
+ .where(eq(testAttempt.id, input.id))
104
+ .limit(1);
105
+
106
+ if (!attempt) return null;
107
+ if (attempt.userId !== userId) throw new Error("Not authorized");
108
+
109
+ const dbSections = await db
110
+ .select()
111
+ .from(sectionResult)
112
+ .where(eq(sectionResult.attemptId, input.id))
113
+ .orderBy(sectionResult.createdAt);
114
+
115
+ const sectionResultIds = dbSections.map((s) => s.id);
116
+
117
+ let answers: any[] = [];
118
+ if (sectionResultIds.length > 0) {
119
+ answers = await db
120
+ .select({
121
+ id: answer.id,
122
+ sectionResultId: answer.sectionResultId,
123
+ questionId: answer.questionId,
124
+ userAnswer: answer.userAnswer,
125
+ isCorrect: answer.isCorrect,
126
+ timeSpentSec: answer.timeSpentSec,
127
+ createdAt: answer.createdAt,
128
+ question: {
129
+ id: question.id,
130
+ format: question.format,
131
+ passageText: question.passageText,
132
+ questionText: question.questionText,
133
+ options: question.options,
134
+ correctAnswer: question.correctAnswer,
135
+ explanation: question.explanation,
136
+ difficulty: question.difficulty,
137
+ skillTags: question.skillTags,
138
+ },
139
+ })
140
+ .from(answer)
141
+ .innerJoin(question, eq(answer.questionId, question.id))
142
+ .where(inArray(answer.sectionResultId, sectionResultIds));
143
+ }
144
+
145
+ const pkgSections = await db
146
+ .select({
147
+ id: packageSection.id,
148
+ sectionTypeId: packageSection.sectionTypeId,
149
+ title: packageSection.title,
150
+ orderIndex: packageSection.orderIndex,
151
+ })
152
+ .from(packageSection)
153
+ .where(eq(packageSection.packageId, attempt.packageId!))
154
+ .orderBy(packageSection.orderIndex);
155
+
156
+ const psIds = pkgSections.map((s) => s.id);
157
+ let sectionQuestions: any[] = [];
158
+ if (psIds.length > 0) {
159
+ const sqs = await db
160
+ .select({
161
+ sectionId: sectionQuestion.sectionId,
162
+ questionId: sectionQuestion.questionId,
163
+ orderIndex: sectionQuestion.orderIndex,
164
+ })
165
+ .from(sectionQuestion)
166
+ .where(inArray(sectionQuestion.sectionId, psIds));
167
+
168
+ if (sqs.length > 0) {
169
+ const qIds = sqs.map((sq) => sq.questionId);
170
+ const qs = await db
171
+ .select()
172
+ .from(question)
173
+ .where(inArray(question.id, qIds));
174
+
175
+ const qMap = new Map(qs.map((q) => [q.id, q]));
176
+ sectionQuestions = sqs.map((sq) => ({
177
+ ...sq,
178
+ question: qMap.get(sq.questionId),
179
+ }));
180
+ }
181
+ }
182
+
183
+ // Zip packageSections with dbSections by creation order (both ordered)
184
+ const sectionsWithData = pkgSections.map((pkgSec, idx) => {
185
+ const secResult = dbSections[idx];
186
+ const secAnswers = answers.filter((a) => a.sectionResultId === secResult?.id);
187
+ const secQuestions = sectionQuestions
188
+ .filter((sq) => sq.sectionId === pkgSec.id)
189
+ .sort((a, b) => a.orderIndex - b.orderIndex)
190
+ .map((sq) => sq.question);
191
+
192
+ return {
193
+ ...pkgSec,
194
+ sectionResultId: secResult?.id,
195
+ score: secResult?.score,
196
+ maxScore: secResult?.maxScore,
197
+ timeSpentSec: secResult?.timeSpentSec,
198
+ answers: secAnswers,
199
+ questions: secQuestions,
200
+ };
201
+ });
202
+
203
+ return {
204
+ ...attempt,
205
+ sections: sectionsWithData,
206
+ };
207
+ }),
208
+
209
+ submitAnswer: protectedProcedure
210
+ .input(
211
+ z.object({
212
+ attemptId: z.string().uuid(),
213
+ sectionResultId: z.string().uuid(),
214
+ questionId: z.string().uuid(),
215
+ userAnswer: z.string(),
216
+ timeSpentSec: z.number().optional(),
217
+ }),
218
+ )
219
+ .mutation(async ({ ctx, input }) => {
220
+ const userId = ctx.session.user.id;
221
+
222
+ const [attempt] = await db
223
+ .select()
224
+ .from(testAttempt)
225
+ .where(eq(testAttempt.id, input.attemptId))
226
+ .limit(1);
227
+
228
+ if (!attempt) throw new Error("Attempt not found");
229
+ if (attempt.userId !== userId) throw new Error("Not authorized");
230
+ if (attempt.status !== "in_progress") throw new Error("Attempt already finished");
231
+
232
+ const [q] = await db
233
+ .select()
234
+ .from(question)
235
+ .where(eq(question.id, input.questionId))
236
+ .limit(1);
237
+
238
+ if (!q) throw new Error("Question not found");
239
+
240
+ const isCorrect = normalizeAnswer(q.format, input.userAnswer, q.correctAnswer);
241
+
242
+ const [existing] = await db
243
+ .select()
244
+ .from(answer)
245
+ .where(
246
+ and(
247
+ eq(answer.sectionResultId, input.sectionResultId),
248
+ eq(answer.questionId, input.questionId),
249
+ ),
250
+ )
251
+ .limit(1);
252
+
253
+ if (existing) {
254
+ await db
255
+ .update(answer)
256
+ .set({
257
+ userAnswer: input.userAnswer,
258
+ isCorrect,
259
+ timeSpentSec: input.timeSpentSec,
260
+ })
261
+ .where(eq(answer.id, existing.id));
262
+ } else {
263
+ await db.insert(answer).values({
264
+ sectionResultId: input.sectionResultId,
265
+ questionId: input.questionId,
266
+ userAnswer: input.userAnswer,
267
+ isCorrect,
268
+ timeSpentSec: input.timeSpentSec,
269
+ });
270
+ }
271
+
272
+ return { isCorrect };
273
+ }),
274
+
275
+ finish: protectedProcedure
276
+ .input(z.object({ attemptId: z.string().uuid() }))
277
+ .mutation(async ({ ctx, input }) => {
278
+ const userId = ctx.session.user.id;
279
+
280
+ const [attempt] = await db
281
+ .select()
282
+ .from(testAttempt)
283
+ .where(eq(testAttempt.id, input.attemptId))
284
+ .limit(1);
285
+
286
+ if (!attempt) throw new Error("Attempt not found");
287
+ if (attempt.userId !== userId) throw new Error("Not authorized");
288
+ if (attempt.status !== "in_progress") throw new Error("Attempt already finished");
289
+
290
+ const dbSections = await db
291
+ .select()
292
+ .from(sectionResult)
293
+ .where(eq(sectionResult.attemptId, input.attemptId))
294
+ .orderBy(sectionResult.createdAt);
295
+
296
+ const pkgSections = await db
297
+ .select({ id: packageSection.id })
298
+ .from(packageSection)
299
+ .where(eq(packageSection.packageId, attempt.packageId!))
300
+ .orderBy(packageSection.orderIndex);
301
+
302
+ // Count questions per packageSection
303
+ const psIds = pkgSections.map((s) => s.id);
304
+ let questionCounts = new Map<string, number>();
305
+ if (psIds.length > 0) {
306
+ const counts = await db
307
+ .select({
308
+ sectionId: sectionQuestion.sectionId,
309
+ count: sql<number>`count(*)`,
310
+ })
311
+ .from(sectionQuestion)
312
+ .where(inArray(sectionQuestion.sectionId, psIds))
313
+ .groupBy(sectionQuestion.sectionId);
314
+
315
+ for (const c of counts) {
316
+ questionCounts.set(c.sectionId, c.count);
317
+ }
318
+ }
319
+
320
+ let totalScore = 0;
321
+
322
+ for (let i = 0; i < dbSections.length; i++) {
323
+ const secResult = dbSections[i];
324
+ const pkgSec = pkgSections[i];
325
+ if (!secResult || !pkgSec) continue;
326
+
327
+ const secAnswers = await db
328
+ .select()
329
+ .from(answer)
330
+ .where(eq(answer.sectionResultId, secResult.id));
331
+
332
+ const sectionScore = secAnswers.filter((a) => a.isCorrect).length;
333
+ const sectionMax = questionCounts.get(pkgSec.id) ?? secAnswers.length;
334
+
335
+ await db
336
+ .update(sectionResult)
337
+ .set({
338
+ score: sectionScore,
339
+ maxScore: sectionMax,
340
+ })
341
+ .where(eq(sectionResult.id, secResult.id));
342
+
343
+ totalScore += sectionScore;
344
+ }
345
+
346
+ // Total questions in package
347
+ let totalQuestions = 0;
348
+ for (const count of questionCounts.values()) {
349
+ totalQuestions += count;
350
+ }
351
+
352
+ await db
353
+ .update(testAttempt)
354
+ .set({
355
+ status: "completed",
356
+ finishedAt: new Date(),
357
+ totalScore,
358
+ maxScore: totalQuestions,
359
+ })
360
+ .where(eq(testAttempt.id, input.attemptId));
361
+
362
+ return {
363
+ totalScore,
364
+ maxScore: totalQuestions,
365
+ percentage: totalQuestions > 0 ? Math.round((totalScore / totalQuestions) * 100) : 0,
366
+ };
367
+ }),
368
+
369
+ myAttempts: protectedProcedure
370
+ .input(
371
+ z
372
+ .object({
373
+ packageId: z.string().uuid().optional(),
374
+ limit: z.number().min(1).max(50).default(20),
375
+ offset: z.number().min(0).default(0),
376
+ })
377
+ .optional(),
378
+ )
379
+ .query(async ({ ctx, input }) => {
380
+ const userId = ctx.session.user.id;
381
+ const conditions = [eq(testAttempt.userId, userId)];
382
+
383
+ if (input?.packageId) {
384
+ conditions.push(eq(testAttempt.packageId, input.packageId));
385
+ }
386
+
387
+ const where = and(...conditions);
388
+
389
+ const rows = await db
390
+ .select({
391
+ id: testAttempt.id,
392
+ packageId: testAttempt.packageId,
393
+ comboId: testAttempt.comboId,
394
+ startedAt: testAttempt.startedAt,
395
+ finishedAt: testAttempt.finishedAt,
396
+ totalScore: testAttempt.totalScore,
397
+ maxScore: testAttempt.maxScore,
398
+ status: testAttempt.status,
399
+ createdAt: testAttempt.createdAt,
400
+ })
401
+ .from(testAttempt)
402
+ .where(where)
403
+ .orderBy(desc(testAttempt.createdAt))
404
+ .limit(input?.limit ?? 20)
405
+ .offset(input?.offset ?? 0);
406
+
407
+ const [countResult] = await db
408
+ .select({ count: sql<number>`count(*)` })
409
+ .from(testAttempt)
410
+ .where(where);
411
+ const totalCount = Number(countResult?.count ?? 0);
412
+
413
+ return { attempts: rows, total: totalCount };
414
+ }),
415
+ });
packages/api/src/routers/combo.ts ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { eq, and, desc, sql, inArray, or } from "drizzle-orm";
3
+ import { router, protectedProcedure, publicProcedure } from "../index";
4
+ import { db } from "@labas/db";
5
+ import {
6
+ comboPackage,
7
+ comboSection,
8
+ testPackage,
9
+ packageSection,
10
+ sectionQuestion,
11
+ question,
12
+ examType,
13
+ sectionType,
14
+ user,
15
+ } from "@labas/db";
16
+
17
+ export const comboRouter = router({
18
+ list: publicProcedure
19
+ .input(
20
+ z
21
+ .object({
22
+ isPublic: z.boolean().optional(),
23
+ search: z.string().optional(),
24
+ limit: z.number().min(1).max(50).default(20),
25
+ offset: z.number().min(0).default(0),
26
+ })
27
+ .optional(),
28
+ )
29
+ .query(async ({ ctx, input }) => {
30
+ const userId = ctx.session?.user.id;
31
+ const conditions = [];
32
+
33
+ if (input?.isPublic !== undefined) {
34
+ conditions.push(eq(comboPackage.isPublic, input.isPublic));
35
+ } else if (!userId) {
36
+ conditions.push(eq(comboPackage.isPublic, true));
37
+ }
38
+
39
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
40
+
41
+ const rows = await db
42
+ .select({
43
+ id: comboPackage.id,
44
+ title: comboPackage.title,
45
+ description: comboPackage.description,
46
+ creatorUserId: comboPackage.creatorUserId,
47
+ isPublic: comboPackage.isPublic,
48
+ createdAt: comboPackage.createdAt,
49
+ updatedAt: comboPackage.updatedAt,
50
+ creatorName: user.name,
51
+ })
52
+ .from(comboPackage)
53
+ .leftJoin(user, eq(comboPackage.creatorUserId, user.id))
54
+ .where(where)
55
+ .orderBy(desc(comboPackage.createdAt))
56
+ .limit(input?.limit ?? 20)
57
+ .offset(input?.offset ?? 0);
58
+
59
+ const [countResult] = await db
60
+ .select({ count: sql<number>`count(*)` })
61
+ .from(comboPackage)
62
+ .where(where);
63
+
64
+ return { combos: rows, total: Number(countResult?.count ?? 0) };
65
+ }),
66
+
67
+ myCombos: protectedProcedure
68
+ .input(
69
+ z
70
+ .object({
71
+ search: z.string().optional(),
72
+ limit: z.number().min(1).max(50).default(20),
73
+ offset: z.number().min(0).default(0),
74
+ })
75
+ .optional(),
76
+ )
77
+ .query(async ({ ctx, input }) => {
78
+ const userId = ctx.session.user.id;
79
+ const conditions = [eq(comboPackage.creatorUserId, userId)];
80
+ const where = and(...conditions);
81
+
82
+ const rows = await db
83
+ .select({
84
+ id: comboPackage.id,
85
+ title: comboPackage.title,
86
+ description: comboPackage.description,
87
+ isPublic: comboPackage.isPublic,
88
+ createdAt: comboPackage.createdAt,
89
+ updatedAt: comboPackage.updatedAt,
90
+ })
91
+ .from(comboPackage)
92
+ .where(where)
93
+ .orderBy(desc(comboPackage.createdAt))
94
+ .limit(input?.limit ?? 20)
95
+ .offset(input?.offset ?? 0);
96
+
97
+ const [countResult] = await db
98
+ .select({ count: sql<number>`count(*)` })
99
+ .from(comboPackage)
100
+ .where(where);
101
+
102
+ return { combos: rows, total: Number(countResult?.count ?? 0) };
103
+ }),
104
+
105
+ getById: publicProcedure
106
+ .input(z.object({ id: z.string().uuid() }))
107
+ .query(async ({ ctx, input }) => {
108
+ const userId = ctx.session?.user.id;
109
+
110
+ const [combo] = await db
111
+ .select({
112
+ id: comboPackage.id,
113
+ title: comboPackage.title,
114
+ description: comboPackage.description,
115
+ creatorUserId: comboPackage.creatorUserId,
116
+ isPublic: comboPackage.isPublic,
117
+ createdAt: comboPackage.createdAt,
118
+ updatedAt: comboPackage.updatedAt,
119
+ creatorName: user.name,
120
+ })
121
+ .from(comboPackage)
122
+ .leftJoin(user, eq(comboPackage.creatorUserId, user.id))
123
+ .where(eq(comboPackage.id, input.id))
124
+ .limit(1);
125
+
126
+ if (!combo) return null;
127
+ if (!combo.isPublic && combo.creatorUserId !== userId) return null;
128
+
129
+ // Fetch combo sections with source data
130
+ const sections = await db
131
+ .select({
132
+ id: comboSection.id,
133
+ comboId: comboSection.comboId,
134
+ sourcePackageId: comboSection.sourcePackageId,
135
+ sourceSectionId: comboSection.sourceSectionId,
136
+ orderIndex: comboSection.orderIndex,
137
+ packageTitle: testPackage.title,
138
+ sectionTitle: packageSection.title,
139
+ sectionTypeName: sectionType.name,
140
+ examTypeName: examType.name,
141
+ })
142
+ .from(comboSection)
143
+ .leftJoin(testPackage, eq(comboSection.sourcePackageId, testPackage.id))
144
+ .leftJoin(packageSection, eq(comboSection.sourceSectionId, packageSection.id))
145
+ .leftJoin(sectionType, eq(packageSection.sectionTypeId, sectionType.id))
146
+ .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
147
+ .where(eq(comboSection.comboId, input.id))
148
+ .orderBy(comboSection.orderIndex);
149
+
150
+ // Fetch questions for each section
151
+ const sectionIds = sections.map((s) => s.sourceSectionId);
152
+ let sectionQuestions: Array<{
153
+ sectionId: string;
154
+ questionId: string;
155
+ orderIndex: number;
156
+ question: any;
157
+ }> = [];
158
+
159
+ if (sectionIds.length > 0) {
160
+ const sqs = await db
161
+ .select({
162
+ sectionId: sectionQuestion.sectionId,
163
+ questionId: sectionQuestion.questionId,
164
+ orderIndex: sectionQuestion.orderIndex,
165
+ })
166
+ .from(sectionQuestion)
167
+ .where(inArray(sectionQuestion.sectionId, sectionIds));
168
+
169
+ if (sqs.length > 0) {
170
+ const questionIds = sqs.map((sq) => sq.questionId);
171
+ const qs = await db
172
+ .select()
173
+ .from(question)
174
+ .where(inArray(question.id, questionIds));
175
+
176
+ const questionMap = new Map(qs.map((q) => [q.id, q]));
177
+ sectionQuestions = sqs.map((sq) => ({
178
+ ...sq,
179
+ question: questionMap.get(sq.questionId),
180
+ }));
181
+ }
182
+ }
183
+
184
+ const sectionsWithQuestions = sections.map((section) => ({
185
+ ...section,
186
+ questions: sectionQuestions
187
+ .filter((sq) => sq.sectionId === section.sourceSectionId)
188
+ .sort((a, b) => a.orderIndex - b.orderIndex)
189
+ .map((sq) => sq.question),
190
+ }));
191
+
192
+ return { ...combo, sections: sectionsWithQuestions };
193
+ }),
194
+
195
+ create: protectedProcedure
196
+ .input(
197
+ z.object({
198
+ title: z.string().min(1).max(200),
199
+ description: z.string().optional(),
200
+ isPublic: z.boolean().default(false),
201
+ sections: z.array(
202
+ z.object({
203
+ sourcePackageId: z.string().uuid(),
204
+ sourceSectionId: z.string().uuid(),
205
+ orderIndex: z.number().default(0),
206
+ }),
207
+ ).min(1),
208
+ }),
209
+ )
210
+ .mutation(async ({ ctx, input }) => {
211
+ const { sections, ...pkgData } = input;
212
+
213
+ const [combo] = await db
214
+ .insert(comboPackage)
215
+ .values({
216
+ ...pkgData,
217
+ creatorUserId: ctx.session.user.id,
218
+ })
219
+ .returning();
220
+
221
+ if (!combo) {
222
+ throw new Error("Failed to create combo package");
223
+ }
224
+
225
+ await db.insert(comboSection).values(
226
+ sections.map((s) => ({
227
+ comboId: combo.id,
228
+ ...s,
229
+ })),
230
+ );
231
+
232
+ return combo;
233
+ }),
234
+
235
+ update: protectedProcedure
236
+ .input(
237
+ z.object({
238
+ id: z.string().uuid(),
239
+ title: z.string().min(1).max(200).optional(),
240
+ description: z.string().optional(),
241
+ isPublic: z.boolean().optional(),
242
+ }),
243
+ )
244
+ .mutation(async ({ ctx, input }) => {
245
+ const { id, ...data } = input;
246
+ const [combo] = await db
247
+ .update(comboPackage)
248
+ .set(data)
249
+ .where(
250
+ and(eq(comboPackage.id, id), eq(comboPackage.creatorUserId, ctx.session.user.id)),
251
+ )
252
+ .returning();
253
+ return combo ?? null;
254
+ }),
255
+
256
+ delete: protectedProcedure
257
+ .input(z.object({ id: z.string().uuid() }))
258
+ .mutation(async ({ ctx, input }) => {
259
+ await db
260
+ .delete(comboPackage)
261
+ .where(
262
+ and(eq(comboPackage.id, input.id), eq(comboPackage.creatorUserId, ctx.session.user.id)),
263
+ );
264
+ return { success: true };
265
+ }),
266
+
267
+ // Get available sections from packages for combiner
268
+ availableSections: protectedProcedure
269
+ .input(
270
+ z.object({
271
+ examTypeId: z.string().optional(),
272
+ search: z.string().optional(),
273
+ limit: z.number().min(1).max(50).default(20),
274
+ offset: z.number().min(0).default(0),
275
+ }).optional(),
276
+ )
277
+ .query(async ({ ctx, input }) => {
278
+ const userId = ctx.session.user.id;
279
+
280
+ // Get packages the user can access (public or owned)
281
+ const pkgConditions = [
282
+ and(
283
+ eq(testPackage.isPublic, true),
284
+ eq(testPackage.creatorUserId, userId),
285
+ ),
286
+ ];
287
+
288
+ if (input?.examTypeId) {
289
+ pkgConditions.push(eq(testPackage.examTypeId, input.examTypeId));
290
+ }
291
+
292
+ const packages = await db
293
+ .select({
294
+ id: testPackage.id,
295
+ title: testPackage.title,
296
+ examTypeId: testPackage.examTypeId,
297
+ creatorUserId: testPackage.creatorUserId,
298
+ isPublic: testPackage.isPublic,
299
+ examTypeName: examType.name,
300
+ })
301
+ .from(testPackage)
302
+ .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
303
+ .where(
304
+ or(
305
+ eq(testPackage.isPublic, true),
306
+ eq(testPackage.creatorUserId, userId),
307
+ ),
308
+ )
309
+ .limit(input?.limit ?? 20)
310
+ .offset(input?.offset ?? 0);
311
+
312
+ const packageIds = packages.map((p) => p.id);
313
+
314
+ const sections = await db
315
+ .select({
316
+ id: packageSection.id,
317
+ packageId: packageSection.packageId,
318
+ sectionTypeId: packageSection.sectionTypeId,
319
+ title: packageSection.title,
320
+ orderIndex: packageSection.orderIndex,
321
+ packageTitle: testPackage.title,
322
+ examTypeName: examType.name,
323
+ sectionTypeName: sectionType.name,
324
+ })
325
+ .from(packageSection)
326
+ .leftJoin(testPackage, eq(packageSection.packageId, testPackage.id))
327
+ .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
328
+ .leftJoin(sectionType, eq(packageSection.sectionTypeId, sectionType.id))
329
+ .where(inArray(packageSection.packageId, packageIds))
330
+ .orderBy(testPackage.title, packageSection.orderIndex);
331
+
332
+ return { packages, sections };
333
+ }),
334
+ });
335
+
packages/api/src/routers/index.ts CHANGED
@@ -1,5 +1,7 @@
1
  import { protectedProcedure, publicProcedure, router } from "../index";
2
  import { aiRouter } from "./ai";
 
 
3
  import { questionRouter } from "./question";
4
  import { packageRouter } from "./package";
5
  import { ratingRouter } from "./rating";
@@ -16,6 +18,8 @@ export const appRouter = router({
16
  };
17
  }),
18
  ai: aiRouter,
 
 
19
  question: questionRouter,
20
  package: packageRouter,
21
  rating: ratingRouter,
 
1
  import { protectedProcedure, publicProcedure, router } from "../index";
2
  import { aiRouter } from "./ai";
3
+ import { attemptRouter } from "./attempt";
4
+ import { comboRouter } from "./combo";
5
  import { questionRouter } from "./question";
6
  import { packageRouter } from "./package";
7
  import { ratingRouter } from "./rating";
 
18
  };
19
  }),
20
  ai: aiRouter,
21
+ attempt: attemptRouter,
22
+ combo: comboRouter,
23
  question: questionRouter,
24
  package: packageRouter,
25
  rating: ratingRouter,
packages/api/src/routers/question.ts CHANGED
@@ -14,6 +14,7 @@ export const questionRouter = router({
14
  format: z.string().optional(),
15
  difficulty: z.number().optional(),
16
  isPublic: z.boolean().optional(),
 
17
  search: z.string().optional(),
18
  skillTags: z.array(z.string()).optional(),
19
  limit: z.number().min(1).max(50).default(20),
@@ -35,9 +36,17 @@ export const questionRouter = router({
35
  );
36
  }
37
 
38
- if (input?.isPublic !== undefined) {
 
 
39
  conditions.push(eq(question.isPublic, input.isPublic));
40
- } else if (!userId) {
 
 
 
 
 
 
41
  conditions.push(eq(question.isPublic, true));
42
  }
43
 
 
14
  format: z.string().optional(),
15
  difficulty: z.number().optional(),
16
  isPublic: z.boolean().optional(),
17
+ creatorUserId: z.string().optional(),
18
  search: z.string().optional(),
19
  skillTags: z.array(z.string()).optional(),
20
  limit: z.number().min(1).max(50).default(20),
 
36
  );
37
  }
38
 
39
+ if (input?.creatorUserId) {
40
+ conditions.push(eq(question.creatorUserId, input.creatorUserId));
41
+ } else if (input?.isPublic !== undefined) {
42
  conditions.push(eq(question.isPublic, input.isPublic));
43
+ } else if (userId) {
44
+ // Default for authenticated users: show public questions + their own private questions
45
+ conditions.push(
46
+ or(eq(question.isPublic, true), eq(question.creatorUserId, userId)),
47
+ );
48
+ } else {
49
+ // Default for guests: only public questions
50
  conditions.push(eq(question.isPublic, true));
51
  }
52
 
packages/db/docker-compose.yml CHANGED
@@ -19,5 +19,20 @@ services:
19
  retries: 5
20
  restart: unless-stopped
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  volumes:
23
  labas_postgres_data:
 
 
19
  retries: 5
20
  restart: unless-stopped
21
 
22
+ redis:
23
+ image: redis:7-alpine
24
+ container_name: labas-redis
25
+ ports:
26
+ - "6379:6379"
27
+ volumes:
28
+ - labas_redis_data:/data
29
+ healthcheck:
30
+ test: ["CMD", "redis-cli", "ping"]
31
+ interval: 10s
32
+ timeout: 5s
33
+ retries: 5
34
+ restart: unless-stopped
35
+
36
  volumes:
37
  labas_postgres_data:
38
+ labas_redis_data:
packages/db/src/schema/app.ts CHANGED
@@ -427,3 +427,43 @@ export const questionRatingRelations = relations(questionRating, ({ one }) => ({
427
  references: [question.id],
428
  }),
429
  }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  references: [question.id],
428
  }),
429
  }));
430
+
431
+ // ── Generation Jobs (Background AI Jobs) ───────────────────
432
+
433
+ export const generationJob = pgTable(
434
+ "generation_job",
435
+ {
436
+ id: uuid("id").defaultRandom().primaryKey(),
437
+ userId: text("user_id")
438
+ .notNull()
439
+ .references(() => user.id, { onDelete: "cascade" }),
440
+ status: text("status").notNull().default("pending"), // "pending" | "running" | "completed" | "failed"
441
+ mode: text("mode").notNull().default("quick"), // "quick" | "agentic"
442
+ examTypeId: text("exam_type_id").notNull(),
443
+ sectionTypeId: text("section_type_id").notNull(),
444
+ questionCount: integer("question_count").notNull(),
445
+ progress: integer("progress").default(0).notNull(), // 0-100
446
+ progressMessage: text("progress_message"),
447
+ resultJson: jsonb("result_json"), // GenerationResult
448
+ errorMessage: text("error_message"),
449
+ tokensUsed: integer("tokens_used"),
450
+ durationMs: integer("duration_ms"),
451
+ createdAt: timestamp("created_at").defaultNow().notNull(),
452
+ updatedAt: timestamp("updated_at")
453
+ .defaultNow()
454
+ .$onUpdate(() => new Date())
455
+ .notNull(),
456
+ completedAt: timestamp("completed_at"),
457
+ },
458
+ (table) => [
459
+ index("generationJob_userId_idx").on(table.userId),
460
+ index("generationJob_status_idx").on(table.status),
461
+ ],
462
+ );
463
+
464
+ export const generationJobRelations = relations(generationJob, ({ one }) => ({
465
+ user: one(user, {
466
+ fields: [generationJob.userId],
467
+ references: [user.id],
468
+ }),
469
+ }));
packages/env/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { z } from "zod";
5
  export const env = createEnv({
6
  server: {
7
  DATABASE_URL: z.string().min(1),
 
8
  BETTER_AUTH_SECRET: z.string().min(32),
9
  BETTER_AUTH_URL: z.url(),
10
  CORS_ORIGIN: z.url(),
 
5
  export const env = createEnv({
6
  server: {
7
  DATABASE_URL: z.string().min(1),
8
+ REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
9
  BETTER_AUTH_SECRET: z.string().min(32),
10
  BETTER_AUTH_URL: z.url(),
11
  CORS_ORIGIN: z.url(),
skills-lock.json CHANGED
@@ -6,6 +6,11 @@
6
  "sourceType": "github",
7
  "computedHash": "a4c830509e85557b59339d8d93a4e243e9e59c686e7678854d39230e12c2a6dc"
8
  },
 
 
 
 
 
9
  "hono": {
10
  "source": "yusukebe/hono-skill",
11
  "sourceType": "github",
 
6
  "sourceType": "github",
7
  "computedHash": "a4c830509e85557b59339d8d93a4e243e9e59c686e7678854d39230e12c2a6dc"
8
  },
9
+ "caveman-compress": {
10
+ "source": "juliusbrussee/caveman",
11
+ "sourceType": "github",
12
+ "computedHash": "300fb8578258161e1752a2a4142a7e9ff178c960bcb83b84422e2987421f33bf"
13
+ },
14
  "hono": {
15
  "source": "yusukebe/hono-skill",
16
  "sourceType": "github",