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 +3 -0
- apps/web/src/components/sidebar.tsx +3 -2
- apps/web/src/hooks/use-api-key.ts +1 -0
- apps/web/src/routeTree.gen.ts +95 -53
- apps/web/src/routes/attempt.$id.tsx +265 -0
- apps/web/src/routes/bank.$id.tsx +0 -230
- apps/web/src/routes/bank.tsx +971 -20
- apps/web/src/routes/{builder.tsx β builder.combo.tsx} +140 -176
- apps/web/src/routes/generate.tsx +170 -172
- apps/web/src/routes/jobs.tsx +225 -0
- apps/web/src/routes/package.$id.take.tsx +559 -0
- apps/web/src/routes/package.$id.tsx +8 -8
- apps/web/src/routes/packages.tsx +1 -1
- apps/web/src/routes/settings.tsx +26 -10
- bun.lock +98 -5
- packages/ai/src/agentic.ts +336 -0
- packages/ai/src/client.ts +154 -13
- packages/ai/src/index.ts +2 -0
- packages/ai/src/pipeline.ts +78 -23
- packages/ai/src/prompts.ts +2 -2
- packages/ai/src/schemas.ts +1 -0
- packages/api/package.json +3 -0
- packages/api/src/logger.ts +29 -0
- packages/api/src/queue.ts +262 -0
- packages/api/src/routers/ai.ts +39 -5
- packages/api/src/routers/attempt.ts +415 -0
- packages/api/src/routers/combo.ts +335 -0
- packages/api/src/routers/index.ts +4 -0
- packages/api/src/routers/question.ts +11 -2
- packages/db/docker-compose.yml +15 -0
- packages/db/src/schema/app.ts +40 -0
- packages/env/src/server.ts +1 -0
- skills-lock.json +5 -0
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: "
|
| 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
|
|
|
|
|
|
|
| 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
|
| 63 |
-
id: '/
|
| 64 |
-
path: '/
|
| 65 |
-
getParentRoute: () =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
} as any)
|
| 67 |
|
| 68 |
export interface FileRoutesByFullPath {
|
| 69 |
'/': typeof IndexRoute
|
| 70 |
-
'/bank': typeof
|
| 71 |
-
'/builder': typeof BuilderRoute
|
| 72 |
'/generate': typeof GenerateRoute
|
|
|
|
| 73 |
'/login': typeof LoginRoute
|
| 74 |
'/packages': typeof PackagesRoute
|
| 75 |
'/settings': typeof SettingsRoute
|
| 76 |
-
'/
|
| 77 |
-
'/
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
export interface FileRoutesByTo {
|
| 80 |
'/': typeof IndexRoute
|
| 81 |
-
'/bank': typeof
|
| 82 |
-
'/builder': typeof BuilderRoute
|
| 83 |
'/generate': typeof GenerateRoute
|
|
|
|
| 84 |
'/login': typeof LoginRoute
|
| 85 |
'/packages': typeof PackagesRoute
|
| 86 |
'/settings': typeof SettingsRoute
|
| 87 |
-
'/
|
| 88 |
-
'/
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
export interface FileRoutesById {
|
| 91 |
__root__: typeof rootRouteImport
|
| 92 |
'/': typeof IndexRoute
|
| 93 |
-
'/bank': typeof
|
| 94 |
-
'/builder': typeof BuilderRoute
|
| 95 |
'/generate': typeof GenerateRoute
|
|
|
|
| 96 |
'/login': typeof LoginRoute
|
| 97 |
'/packages': typeof PackagesRoute
|
| 98 |
'/settings': typeof SettingsRoute
|
| 99 |
-
'/
|
| 100 |
-
'/
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
export interface FileRouteTypes {
|
| 103 |
fileRoutesByFullPath: FileRoutesByFullPath
|
| 104 |
fullPaths:
|
| 105 |
| '/'
|
| 106 |
| '/bank'
|
| 107 |
-
| '/builder'
|
| 108 |
| '/generate'
|
|
|
|
| 109 |
| '/login'
|
| 110 |
| '/packages'
|
| 111 |
| '/settings'
|
| 112 |
-
| '/
|
|
|
|
| 113 |
| '/package/$id'
|
|
|
|
| 114 |
fileRoutesByTo: FileRoutesByTo
|
| 115 |
to:
|
| 116 |
| '/'
|
| 117 |
| '/bank'
|
| 118 |
-
| '/builder'
|
| 119 |
| '/generate'
|
|
|
|
| 120 |
| '/login'
|
| 121 |
| '/packages'
|
| 122 |
| '/settings'
|
| 123 |
-
| '/
|
|
|
|
| 124 |
| '/package/$id'
|
|
|
|
| 125 |
id:
|
| 126 |
| '__root__'
|
| 127 |
| '/'
|
| 128 |
| '/bank'
|
| 129 |
-
| '/builder'
|
| 130 |
| '/generate'
|
|
|
|
| 131 |
| '/login'
|
| 132 |
| '/packages'
|
| 133 |
| '/settings'
|
| 134 |
-
| '/
|
|
|
|
| 135 |
| '/package/$id'
|
|
|
|
| 136 |
fileRoutesById: FileRoutesById
|
| 137 |
}
|
| 138 |
export interface RootRouteChildren {
|
| 139 |
IndexRoute: typeof IndexRoute
|
| 140 |
-
BankRoute: typeof
|
| 141 |
-
BuilderRoute: typeof BuilderRoute
|
| 142 |
GenerateRoute: typeof GenerateRoute
|
|
|
|
| 143 |
LoginRoute: typeof LoginRoute
|
| 144 |
PackagesRoute: typeof PackagesRoute
|
| 145 |
SettingsRoute: typeof SettingsRoute
|
| 146 |
-
|
|
|
|
| 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 |
-
'/
|
| 208 |
-
id: '/
|
| 209 |
-
path: '/
|
| 210 |
-
fullPath: '/
|
| 211 |
-
preLoaderRoute: typeof
|
| 212 |
-
parentRoute: typeof
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
}
|
| 215 |
}
|
| 216 |
|
| 217 |
-
interface
|
| 218 |
-
|
| 219 |
}
|
| 220 |
|
| 221 |
-
const
|
| 222 |
-
|
| 223 |
}
|
| 224 |
|
| 225 |
-
const
|
|
|
|
|
|
|
| 226 |
|
| 227 |
const rootRouteChildren: RootRouteChildren = {
|
| 228 |
IndexRoute: IndexRoute,
|
| 229 |
-
BankRoute:
|
| 230 |
-
BuilderRoute: BuilderRoute,
|
| 231 |
GenerateRoute: GenerateRoute,
|
|
|
|
| 232 |
LoginRoute: LoginRoute,
|
| 233 |
PackagesRoute: PackagesRoute,
|
| 234 |
SettingsRoute: SettingsRoute,
|
| 235 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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-
|
| 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-
|
| 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 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)]
|
| 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 |
-
|
| 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
|
| 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:
|
| 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 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
-
function
|
| 46 |
const [step, setStep] = useState<"select" | "review">("select");
|
| 47 |
const [search, setSearch] = useState("");
|
| 48 |
const [examTypeFilter, setExamTypeFilter] = useState("");
|
| 49 |
-
const [
|
| 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
|
| 58 |
-
trpc.
|
| 59 |
-
search: search || undefined,
|
| 60 |
examTypeId: examTypeFilter || undefined,
|
| 61 |
-
|
| 62 |
limit: 50,
|
| 63 |
offset: 0,
|
| 64 |
}),
|
| 65 |
);
|
| 66 |
|
| 67 |
-
const
|
| 68 |
-
...trpc.
|
| 69 |
});
|
| 70 |
|
| 71 |
-
const
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
prev.includes(id) ? prev.filter((q) => q !== id) : [...prev, id],
|
| 82 |
-
);
|
| 83 |
};
|
| 84 |
|
| 85 |
-
const
|
| 86 |
-
if (!title ||
|
| 87 |
|
| 88 |
try {
|
| 89 |
-
|
| 90 |
title,
|
| 91 |
description,
|
| 92 |
-
examTypeId: examType,
|
| 93 |
isPublic,
|
| 94 |
-
|
| 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(`
|
| 113 |
-
|
| 114 |
setTitle("");
|
| 115 |
setDescription("");
|
| 116 |
setStep("select");
|
| 117 |
} catch (err: any) {
|
| 118 |
-
alert("Gagal membuat
|
| 119 |
}
|
| 120 |
};
|
| 121 |
|
| 122 |
-
const
|
| 123 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 130 |
</h1>
|
| 131 |
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 132 |
-
|
| 133 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</section>
|
| 135 |
|
| 136 |
{step === "select" ? (
|
| 137 |
<>
|
| 138 |
-
{/*
|
| 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
|
| 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)]">{
|
| 175 |
</span>
|
| 176 |
<Button
|
| 177 |
-
onClick={() =>
|
| 178 |
-
disabled={
|
| 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 |
-
{/*
|
| 188 |
-
{
|
| 189 |
-
<div className="
|
| 190 |
-
{Array.from({ length:
|
| 191 |
-
<Card key={i} className="h-
|
| 192 |
))}
|
| 193 |
</div>
|
| 194 |
-
) :
|
| 195 |
<div className="text-center py-20">
|
| 196 |
-
<MaterialIcon name="
|
| 197 |
-
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada
|
| 198 |
</div>
|
| 199 |
) : (
|
| 200 |
-
<div className="
|
| 201 |
-
{
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
className=
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
{
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
</div>
|
| 235 |
-
</
|
| 236 |
-
|
| 237 |
-
|
| 238 |
</div>
|
| 239 |
)}
|
| 240 |
</>
|
| 241 |
) : (
|
| 242 |
-
/* Review
|
| 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
|
| 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 |
-
|
| 313 |
</h3>
|
| 314 |
<div className="space-y-3">
|
| 315 |
-
{
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
<
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
</
|
| 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={
|
| 339 |
-
disabled={!title ||
|
| 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="
|
| 343 |
<span className="ml-2">
|
| 344 |
-
{
|
| 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 [
|
| 73 |
-
const [
|
| 74 |
-
const [
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
const generate = useMutation({
|
| 78 |
...trpc.ai.generate.mutationOptions(),
|
| 79 |
onSuccess: (data) => {
|
| 80 |
-
|
| 81 |
setError(null);
|
|
|
|
| 82 |
},
|
| 83 |
onError: (err) => {
|
| 84 |
setError(err.message);
|
| 85 |
-
|
| 86 |
},
|
| 87 |
});
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
},
|
| 94 |
});
|
| 95 |
|
| 96 |
-
const
|
| 97 |
-
|
| 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 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
| 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
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
<
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
<
|
| 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 |
-
<
|
| 555 |
key={opt.key}
|
| 556 |
-
className=
|
| 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-
|
| 563 |
-
{opt.key
|
| 564 |
-
<span className="w-2.5 h-2.5 rounded-full bg-[var(--clay-black)]" />
|
| 565 |
-
)}
|
| 566 |
</span>
|
| 567 |
-
<span className=
|
| 568 |
-
|
| 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 |
-
<
|
| 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 |
-
</
|
| 168 |
))}
|
| 169 |
</div>
|
| 170 |
)}
|
|
@@ -175,10 +173,12 @@ function PackageDetailComponent() {
|
|
| 175 |
|
| 176 |
{/* Actions */}
|
| 177 |
<div className="flex gap-3 mt-8">
|
| 178 |
-
<
|
| 179 |
-
<
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
| 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="/
|
| 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="
|
| 118 |
-
<
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 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@
|
| 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
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
{
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
},
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 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 |
-
|
| 44 |
-
|
|
|
|
| 45 |
const cleaned = content
|
| 46 |
.replace(/^```json\s*/, "")
|
| 47 |
.replace(/```\s*$/, "")
|
| 48 |
.trim();
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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
|
| 13 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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?.
|
|
|
|
|
|
|
| 39 |
conditions.push(eq(question.isPublic, input.isPublic));
|
| 40 |
-
} else if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|