feat: update accessibility guidelines and enhance component structure. Introduce new frontend accessibility conventions in AGENTS.md, including heading hierarchy and ARIA roles for dialogs and buttons. Refactor TourGuide and related components for lazy loading and improved user experience. Update ESLint configuration to enforce stricter TypeScript rules and add new dependencies for linting. Enhance various components with accessibility features and ensure consistent styling across the application.
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- AGENTS.md +48 -2
- DESIGN.md +86 -0
- apps/web/eslint.config.mjs +17 -0
- apps/web/package.json +4 -0
- apps/web/src/components/GettingStartedCard.tsx +1 -0
- apps/web/src/components/TourGuide.tsx +11 -80
- apps/web/src/components/TourGuideImpl.tsx +106 -0
- apps/web/src/components/analytics/BreakdownCharts.tsx +3 -3
- apps/web/src/components/analytics/ChartsBundle.tsx +45 -0
- apps/web/src/components/analytics/TimeAnalyticsPanel.tsx +3 -3
- apps/web/src/components/attempt/OptionDisplay.tsx +16 -6
- apps/web/src/components/attempt/QuestionReviewCard.tsx +6 -5
- apps/web/src/components/bank/AutoBundleModal.tsx +14 -19
- apps/web/src/components/bank/BundleSidebar.tsx +7 -6
- apps/web/src/components/bank/CreatePackageModal.tsx +14 -20
- apps/web/src/components/bank/FilterBar.tsx +58 -4
- apps/web/src/components/bank/QuestionCard.tsx +4 -2
- apps/web/src/components/bank/QuestionDetailModal.tsx +31 -33
- apps/web/src/components/bank/SectionBrowser.tsx +6 -5
- apps/web/src/components/bank/SoalBrowser.tsx +8 -7
- apps/web/src/components/generate/ResultSection.tsx +1 -1
- apps/web/src/components/package/EditPackageModal.tsx +14 -20
- apps/web/src/components/routes/BankPage.tsx +36 -33
- apps/web/src/components/routes/GeneratePage.tsx +48 -14
- apps/web/src/components/routes/PackagesPage.tsx +22 -22
- apps/web/src/components/routes/SettingsPage.tsx +2 -2
- apps/web/src/components/settings/AccountSettings.tsx +5 -4
- apps/web/src/components/test/AccentKeyboard.tsx +4 -3
- apps/web/src/components/test/AttemptTestView.tsx +14 -13
- apps/web/src/components/test/QuestionInput.tsx +5 -2
- apps/web/src/components/test/attempt/AbandonDialog.tsx +24 -29
- apps/web/src/components/test/attempt/FinishDialog.tsx +29 -29
- apps/web/src/components/test/attempt/FloatingNav.tsx +24 -1
- apps/web/src/hooks/use-generation-jobs.ts +93 -327
- apps/web/src/hooks/use-job-shared.ts +66 -0
- apps/web/src/hooks/use-job-state.ts +243 -0
- apps/web/src/hooks/use-package-builder.ts +6 -5
- apps/web/src/hooks/use-polling-transport.ts +80 -0
- apps/web/src/hooks/use-question-selection.ts +1 -1
- apps/web/src/hooks/use-test-session.ts +3 -2
- apps/web/src/lib/types.ts +86 -0
- apps/web/src/routes/__root.tsx +13 -0
- apps/web/src/routes/admin.credits.tsx +7 -0
- apps/web/src/routes/admin.featured.tsx +145 -108
- apps/web/src/routes/admin.jobs.tsx +1 -1
- apps/web/src/routes/admin.moderation.tsx +1 -1
- apps/web/src/routes/admin.tsx +1 -1
- apps/web/src/routes/admin.users.tsx +3 -2
- apps/web/src/routes/analytics.tsx +23 -23
- apps/web/src/routes/attempt.$id.tsx +30 -12
AGENTS.md
CHANGED
|
@@ -93,6 +93,9 @@ bun run build # Build all packages
|
|
| 93 |
- β **Do not assume Prisma**. Database layer is **Drizzle ORM**.
|
| 94 |
- β **Do not hardcode API keys** in source code. AI keys are user-managed via the Settings UI.
|
| 95 |
- β **Do not add global CSS** in `apps/web` without checking `packages/ui/src/styles/globals.css` for existing design tokens and CSS variables.
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
---
|
| 98 |
|
|
@@ -109,6 +112,25 @@ bun run build # Build all packages
|
|
| 109 |
npx shadcn@latest add accordion dialog -c packages/ui
|
| 110 |
```
|
| 111 |
- App-specific blocks (non-shared) should be added directly in `apps/web/src/components/`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
---
|
| 114 |
|
|
@@ -187,7 +209,31 @@ bun run build # Build all packages
|
|
| 187 |
- Routers live in `packages/api/src/routers/`.
|
| 188 |
- Register new routers in `packages/api/src/routers/index.ts`.
|
| 189 |
|
| 190 |
-
## 11.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
- Do **NOT** run `git commit`, `git push`, `git rebase`, or force-push unless the user explicitly asks for it.
|
| 193 |
- Do **NOT** create `README.md` or documentation files unless explicitly requested.
|
|
@@ -230,4 +276,4 @@ bun run build # Build all packages
|
|
| 230 |
|
| 231 |
---
|
| 232 |
|
| 233 |
-
_Last updated: 2026-05-
|
|
|
|
| 93 |
- β **Do not assume Prisma**. Database layer is **Drizzle ORM**.
|
| 94 |
- β **Do not hardcode API keys** in source code. AI keys are user-managed via the Settings UI.
|
| 95 |
- β **Do not add global CSS** in `apps/web` without checking `packages/ui/src/styles/globals.css` for existing design tokens and CSS variables.
|
| 96 |
+
- β **Do not skip heading levels**. After `<h1>`, the next heading must be `<h2>`, not `<h3>` (or lower). Never have two `<h1>` on one page.
|
| 97 |
+
- β **Do not create custom modal/dialog components**. Use shadcn `<Dialog>` from `@labas/ui/components/dialog` β it provides built-in focus trap, escape key, scroll lock, and ARIA.
|
| 98 |
+
- β **Do not use `any`** in frontend TypeScript. Enforced by eslint `@typescript-eslint/no-explicit-any: "error"` in `apps/web/eslint.config.mjs`.
|
| 99 |
|
| 100 |
---
|
| 101 |
|
|
|
|
| 112 |
npx shadcn@latest add accordion dialog -c packages/ui
|
| 113 |
```
|
| 114 |
- App-specific blocks (non-shared) should be added directly in `apps/web/src/components/`.
|
| 115 |
+
- DevTools (TanStack Router, React Query) are **production-gated** via `import.meta.env.DEV` β do not remove the condition.
|
| 116 |
+
- App-level error boundary is in `__root.tsx` using `<ErrorFallback>` β route-level error boundaries via `errorComponent` route option.
|
| 117 |
+
- Lazy-loaded route components use the `.lazy.tsx` pattern: route file is thin, heavy component lives in `components/routes/*Page.tsx` wrapped in `Suspense`.
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## 5b. Frontend Accessibility Conventions
|
| 122 |
+
|
| 123 |
+
- **Skip link**: Root layout in `__root.tsx` has a hidden `<SkipLink>` that appears on Tab press, linking to `#main-content` on the `<main>` element.
|
| 124 |
+
- **Icon-only buttons**: Every `<button>` or `<Button>` with only an icon (no visible text) **must** have `aria-label`. The icon element (`<MaterialIcon>`, `<span>`) should NOT have `aria-hidden="true"` unless decorative.
|
| 125 |
+
- **Heading hierarchy**: Never skip levels (`h1 β h2 β h3`, never `h1 β h3`). Exactly one `<h1>` per page. Heading level corresponds to semantic nesting, not visual size.
|
| 126 |
+
- **Form labels**: Every `<Input>`, `<select>`, `<textarea>` needs an associated `<Label>` (with `htmlFor` + `id`) OR an `aria-label` attribute. Search inputs, OTP digits, and icon-only filter buttons are common offenders.
|
| 127 |
+
- **Dialogs**: All modals use the shadcn `<Dialog>` component (from `@labas/ui/components/dialog`), which provides:
|
| 128 |
+
- Focus trap inside dialog
|
| 129 |
+
- Escape key to close
|
| 130 |
+
- Scroll lock on body
|
| 131 |
+
- Focus restoration to trigger element on close
|
| 132 |
+
- ARIA `role="dialog"`, `aria-modal`, `aria-labelledby`
|
| 133 |
+
- **Custom tabs/accordions**: Must have proper ARIA roles (`role="tablist"`, `role="tab"`, `aria-selected`, `tabindex` for roving tabindex) and arrow key handlers. Prefer shadcn `<Tabs>` where possible; use manual ARIA + `handleRovingKeyDown` helper only when tabs have close buttons or other non-standard elements.
|
| 134 |
|
| 135 |
---
|
| 136 |
|
|
|
|
| 209 |
- Routers live in `packages/api/src/routers/`.
|
| 210 |
- Register new routers in `packages/api/src/routers/index.ts`.
|
| 211 |
|
| 212 |
+
## 11. Admin Routes & DataTable
|
| 213 |
+
|
| 214 |
+
### Admin Route Structure
|
| 215 |
+
- Admin routes are flat files: `routes/admin.users.tsx`, `routes/admin.credits.tsx`, etc.
|
| 216 |
+
- Wrapped by `routes/admin.tsx` layout shell (sidebar + admin role check).
|
| 217 |
+
- All admin tRPC procedures live in `packages/api/src/routers/admin.ts` and use `adminProcedure` (extends `protectedProcedure` with role check).
|
| 218 |
+
- Mutations use `audit()` helper for audit logging to `adminAuditLog` table.
|
| 219 |
+
|
| 220 |
+
### DataTable Component (Design Reference)
|
| 221 |
+
- Admin table routes should eventually use a `<DataTable<T>>` component from `components/admin/DataTable.tsx`.
|
| 222 |
+
- API design: generic `<T>`, column defs with `accessorKey`/`accessorFn`/`cell` render prop, `actions` render prop, opt-in pagination, server-side sorting.
|
| 223 |
+
- Search bars, filter pills, and status pill buttons live **above** the DataTable in the route component (not inside it).
|
| 224 |
+
- **Do NOT** use DataTable for card-based layouts (e.g., Featured editor's pick) β keep card layouts as-is.
|
| 225 |
+
|
| 226 |
+
### Current Migration Status
|
| 227 |
+
- `admin.users.tsx` β table-based, good candidate for DataTable
|
| 228 |
+
- `admin.jobs.tsx` β table-based, good candidate
|
| 229 |
+
- `admin.moderation.tsx` β table-based, good candidate
|
| 230 |
+
- `admin.credits.tsx` β transaction table, good candidate
|
| 231 |
+
- `admin.featured.tsx` β card layout, keep as-is
|
| 232 |
+
- `admin.index.tsx` β dashboard stats cards, keep as-is
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 11b. Git & Workflow
|
| 237 |
|
| 238 |
- Do **NOT** run `git commit`, `git push`, `git rebase`, or force-push unless the user explicitly asks for it.
|
| 239 |
- Do **NOT** create `README.md` or documentation files unless explicitly requested.
|
|
|
|
| 276 |
|
| 277 |
---
|
| 278 |
|
| 279 |
+
_Last updated: 2026-05-19_
|
DESIGN.md
CHANGED
|
@@ -302,3 +302,89 @@ What makes Clay truly distinctive is its hover micro-animations: buttons on hove
|
|
| 302 |
5. Hover animations are the signature β rotation + hard shadow, not subtle fades
|
| 303 |
6. Generous radius: 24px cards, 40px sections β nothing looks sharp or corporate
|
| 304 |
7. Three weights: 600 (headings), 500 (UI), 400 (body) β strict roles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
5. Hover animations are the signature β rotation + hard shadow, not subtle fades
|
| 303 |
6. Generous radius: 24px cards, 40px sections β nothing looks sharp or corporate
|
| 304 |
7. Three weights: 600 (headings), 500 (UI), 400 (body) β strict roles
|
| 305 |
+
|
| 306 |
+
---
|
| 307 |
+
|
| 308 |
+
## 4. Architectural Decisions (Q2 2026)
|
| 309 |
+
|
| 310 |
+
### 4.1 Component Decomposition
|
| 311 |
+
|
| 312 |
+
| Refactored Component | Original Lines | After | Strategy |
|
| 313 |
+
|---|---|---|---|
|
| 314 |
+
| `AttemptTestView.tsx` | 589 | ~320 | Extract header (AttemptHeader), abandon/finish dialogs, floating nav |
|
| 315 |
+
| `use-generation-jobs.ts` | 391 | ~120 (facade) | Extract `use-job-shared.ts`, `use-job-state.ts`, `use-polling-transport.ts` |
|
| 316 |
+
|
| 317 |
+
### 4.2 Lazy Loading Architecture
|
| 318 |
+
|
| 319 |
+
Heavy route components extracted to `apps/web/src/components/routes/*Page.tsx` and loaded via `.lazy.tsx`:
|
| 320 |
+
- `BankPage`, `GeneratePage`, `LandingPage`, `PackagesPage`, `SettingsPage`
|
| 321 |
+
- Libraries `recharts` and `react-joyride` wrapped in lazy bundles (`ChartsBundle.tsx`, `TourGuideImpl.tsx`)
|
| 322 |
+
- Each chunk loaded on-demand via React.lazy + Suspense
|
| 323 |
+
|
| 324 |
+
### 4.3 Semantic CSS Token Mapping
|
| 325 |
+
|
| 326 |
+
All `packages/ui` primitives use semantic tokens; zero app-specific CSS vars in primitives:
|
| 327 |
+
- `--primary` β `--matcha-600` (green/action)
|
| 328 |
+
- `--secondary` β `--clay-black` (black/neutral)
|
| 329 |
+
- Theme lives in `packages/ui/src/styles/globals.css`
|
| 330 |
+
- App (`apps/web`) imports only `@import "@labas/ui/globals.css"` β no `index.css` overrides
|
| 331 |
+
|
| 332 |
+
### 4.4 Accessibility Decisions
|
| 333 |
+
|
| 334 |
+
| Pattern | Implementation | Files |
|
| 335 |
+
|---------|---------------|-------|
|
| 336 |
+
| Focus trap + escape | shadcn `<Dialog>` (adapter: Base UI) | 6 modals in `components/` |
|
| 337 |
+
| Keyboard tabs | `handleRovingKeyDown` helper + ARIA | `FilterBar.tsx`, `GeneratePage.tsx`, `FloatingNav.tsx` |
|
| 338 |
+
| shadcn Tabs | `<Tabs>` primitive for simple tab groups | `PackagesPage.tsx`, `admin.featured.tsx` |
|
| 339 |
+
| Skip link | `<SkipLink>` component, `#main-content` | `__root.tsx` |
|
| 340 |
+
| Icon buttons | `aria-label` required, `title` insufficient | 16 buttons across 6 files |
|
| 341 |
+
| Heading hierarchy | `h1 β h2 β h3`, never skip | Audited ~15 files |
|
| 342 |
+
|
| 343 |
+
### 4.5 Admin DataTable API (Design)
|
| 344 |
+
|
| 345 |
+
```ts
|
| 346 |
+
interface ColumnDef<T> {
|
| 347 |
+
id: string;
|
| 348 |
+
header: string;
|
| 349 |
+
accessorKey?: keyof T & string;
|
| 350 |
+
accessorFn?: (row: T) => ReactNode;
|
| 351 |
+
cell?: (props: { row: T; value: ReactNode }) => ReactNode;
|
| 352 |
+
sortable?: boolean;
|
| 353 |
+
size?: string;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
interface DataTableProps<T> {
|
| 357 |
+
data: T[];
|
| 358 |
+
columns: ColumnDef<T>[];
|
| 359 |
+
keyExtractor: (row: T) => string | number;
|
| 360 |
+
isLoading?: boolean;
|
| 361 |
+
emptyMessage?: string;
|
| 362 |
+
page?: number;
|
| 363 |
+
totalPages?: number;
|
| 364 |
+
onPageChange?: (p: number) => void;
|
| 365 |
+
sortColumn?: string;
|
| 366 |
+
sortDirection?: "asc" | "desc";
|
| 367 |
+
onSort?: (column: string) => void;
|
| 368 |
+
actions?: (row: T) => ReactNode;
|
| 369 |
+
}
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
- Generic `<T>` for full type safety
|
| 373 |
+
- Opt-in pagination (reuses existing `Pagination` component)
|
| 374 |
+
- Server-side sorting via `sortable` + `onSort` callback
|
| 375 |
+
- Search bars/filter pills sit **above** DataTable, not inside it
|
| 376 |
+
- Card layouts (Featured editor's pick) excluded β DataTable is table-only
|
| 377 |
+
|
| 378 |
+
### 4.6 Job Transport Abstraction
|
| 379 |
+
|
| 380 |
+
Polling implemented as `JobTransport` interface in `packages/ai/src/agentic.ts`:
|
| 381 |
+
- Currently: in-memory `Map` for rate limiting
|
| 382 |
+
- Designed to swap to WebSocket/SSE without changing callers
|
| 383 |
+
- Exported: `checkRateLimit(userId)`, `JobTransport` type
|
| 384 |
+
|
| 385 |
+
### 4.7 Type Safety
|
| 386 |
+
|
| 387 |
+
- `no-explicit-any: "error"` enforced via `apps/web/eslint.config.mjs`
|
| 388 |
+
- Shared frontend types in `apps/web/src/lib/types.ts` β all fields optional (permissive) to accept diverse tRPC shapes without `any`
|
| 389 |
+
- tRPC errors caught as `unknown`, narrowed via `getErrorMessage(e)` helper
|
| 390 |
+
- Zero `any` violations across entire frontend after Q2 2026 sprint
|
apps/web/eslint.config.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import eslint from "@eslint/js";
|
| 2 |
+
import tseslint from "typescript-eslint";
|
| 3 |
+
|
| 4 |
+
export default tseslint.config(
|
| 5 |
+
eslint.configs.recommended,
|
| 6 |
+
...tseslint.configs.recommended,
|
| 7 |
+
{
|
| 8 |
+
files: ["src/**/*.{ts,tsx}"],
|
| 9 |
+
rules: {
|
| 10 |
+
"@typescript-eslint/no-explicit-any": "error",
|
| 11 |
+
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
| 12 |
+
},
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
ignores: ["dist", "node_modules", "src/routeTree.gen.ts"],
|
| 16 |
+
},
|
| 17 |
+
);
|
apps/web/package.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
| 11 |
"start": "vite",
|
| 12 |
"test": "bun test",
|
| 13 |
"check-types": "vite build && tsc --noEmit",
|
|
|
|
| 14 |
"generate-pwa-assets": "pwa-assets-generator"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
|
@@ -41,6 +42,7 @@
|
|
| 41 |
"zod": "catalog:"
|
| 42 |
},
|
| 43 |
"devDependencies": {
|
|
|
|
| 44 |
"@labas/config": "workspace:*",
|
| 45 |
"@tanstack/react-query-devtools": "^5.91.1",
|
| 46 |
"@tanstack/react-router-devtools": "^1.166.13",
|
|
@@ -50,9 +52,11 @@
|
|
| 50 |
"@types/react-dom": "catalog:",
|
| 51 |
"@vite-pwa/assets-generator": "^1.0.2",
|
| 52 |
"@vitejs/plugin-react": "^6.0.1",
|
|
|
|
| 53 |
"postcss": "^8.5.10",
|
| 54 |
"tailwindcss": "^4.2.2",
|
| 55 |
"typescript": "catalog:",
|
|
|
|
| 56 |
"vite": "^8.0.8"
|
| 57 |
}
|
| 58 |
}
|
|
|
|
| 11 |
"start": "vite",
|
| 12 |
"test": "bun test",
|
| 13 |
"check-types": "vite build && tsc --noEmit",
|
| 14 |
+
"lint": "eslint src",
|
| 15 |
"generate-pwa-assets": "pwa-assets-generator"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
|
|
|
| 42 |
"zod": "catalog:"
|
| 43 |
},
|
| 44 |
"devDependencies": {
|
| 45 |
+
"@eslint/js": "^10.0.1",
|
| 46 |
"@labas/config": "workspace:*",
|
| 47 |
"@tanstack/react-query-devtools": "^5.91.1",
|
| 48 |
"@tanstack/react-router-devtools": "^1.166.13",
|
|
|
|
| 52 |
"@types/react-dom": "catalog:",
|
| 53 |
"@vite-pwa/assets-generator": "^1.0.2",
|
| 54 |
"@vitejs/plugin-react": "^6.0.1",
|
| 55 |
+
"eslint": "^10.4.0",
|
| 56 |
"postcss": "^8.5.10",
|
| 57 |
"tailwindcss": "^4.2.2",
|
| 58 |
"typescript": "catalog:",
|
| 59 |
+
"typescript-eslint": "^8.59.4",
|
| 60 |
"vite": "^8.0.8"
|
| 61 |
}
|
| 62 |
}
|
apps/web/src/components/GettingStartedCard.tsx
CHANGED
|
@@ -73,6 +73,7 @@ export function GettingStartedCard() {
|
|
| 73 |
</div>
|
| 74 |
<button
|
| 75 |
onClick={dismiss}
|
|
|
|
| 76 |
className="text-[var(--warm-silver)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
|
| 77 |
>
|
| 78 |
<MaterialIcon name="close" className="text-xl" />
|
|
|
|
| 73 |
</div>
|
| 74 |
<button
|
| 75 |
onClick={dismiss}
|
| 76 |
+
aria-label="Tutup"
|
| 77 |
className="text-[var(--warm-silver)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
|
| 78 |
>
|
| 79 |
<MaterialIcon name="close" className="text-xl" />
|
apps/web/src/components/TourGuide.tsx
CHANGED
|
@@ -1,67 +1,20 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { Joyride, type Step, type EventData } from "react-joyride";
|
| 3 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
|
|
| 4 |
|
| 5 |
-
const
|
| 6 |
-
|
| 7 |
-
textColor: "var(--clay-black)",
|
| 8 |
-
backgroundColor: "var(--pure-white)",
|
| 9 |
-
arrowColor: "var(--pure-white)",
|
| 10 |
-
overlayColor: "rgba(0,0,0,0.4)",
|
| 11 |
-
};
|
| 12 |
|
| 13 |
-
|
| 14 |
-
buttonBack: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 } as any,
|
| 15 |
-
buttonSkip: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 } as any,
|
| 16 |
-
tooltipContainer: { textAlign: "left" } as any,
|
| 17 |
-
tooltipContent: { fontSize: "14px", lineHeight: "1.6", padding: "8px 0" } as any,
|
| 18 |
-
tooltipTitle: { fontSize: "18px", fontWeight: 700, fontFamily: "Manrope, sans-serif" } as any,
|
| 19 |
-
};
|
| 20 |
-
|
| 21 |
-
const joyrideLocale = { back: "Kembali", close: "Tutup", last: "Selesai", next: "Lanjut", skip: "Lewati" };
|
| 22 |
-
|
| 23 |
-
// ββ Global site tour ββ
|
| 24 |
|
| 25 |
const LS_GLOBAL = "labas-tour-completed";
|
| 26 |
const TRIGGER_GLOBAL = "labas-tour-trigger";
|
| 27 |
|
| 28 |
-
const globalSteps: Step[] = [
|
| 29 |
-
{ target: "body" as any, placement: "center", title: "Selamat Datang di Labas!", content: "Platform latihan ujian bahasa berbasis AI. Yuk, kita lihat fitur-fitur utamanya!", hideOverlay: true },
|
| 30 |
-
{ target: "[data-tour='dashboard-stats']", title: "Ringkasan Aktivitas", content: "Pantau jumlah latihan, waktu belajar, soal terjawab, dan akurasi kamu di sini.", spotlightPadding: 8 },
|
| 31 |
-
{ target: "[data-tour='nav-generate']", title: "AI Lab β Generate Soal", content: "Buat soal latihan sendiri pakai AI. Pilih jenis ujian, section, format, dan topik.", spotlightPadding: 4 },
|
| 32 |
-
{ target: "[data-tour='nav-bank']", title: "Bank Soal β Buat Paket", content: "Atur soal-soal kamu jadi paket latihan dari bank soal.", spotlightPadding: 4 },
|
| 33 |
-
{ target: "[data-tour='nav-packages']", title: "Paket Soal β Mulai Latihan", content: "Temukan paket soal dari komunitas atau buatan sendiri.", spotlightPadding: 4 },
|
| 34 |
-
{ target: "[data-tour='nav-analytics']", title: "Analytics β Evaluasi", content: "Lihat analitik mendalam: skor, tren, dan rekomendasi belajar.", spotlightPadding: 4 },
|
| 35 |
-
{ target: "body" as any, placement: "center", title: "Siap Belajar?", content: "Setiap halaman punya panduan sendiri. Cari tombol ? di pojok kanan bawah halaman!", hideOverlay: true },
|
| 36 |
-
];
|
| 37 |
-
|
| 38 |
export function TourGuide() {
|
| 39 |
-
const [run, setRun] = useState(false);
|
| 40 |
-
|
| 41 |
-
useEffect(() => {
|
| 42 |
-
const onTrigger = () => { localStorage.removeItem(LS_GLOBAL); setRun(true); };
|
| 43 |
-
window.addEventListener(TRIGGER_GLOBAL, onTrigger);
|
| 44 |
-
|
| 45 |
-
const completed = localStorage.getItem(LS_GLOBAL);
|
| 46 |
-
if (!completed) {
|
| 47 |
-
const timer = setTimeout(() => setRun(true), 800);
|
| 48 |
-
return () => { clearTimeout(timer); window.removeEventListener(TRIGGER_GLOBAL, onTrigger); };
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
return () => window.removeEventListener(TRIGGER_GLOBAL, onTrigger);
|
| 52 |
-
}, []);
|
| 53 |
-
|
| 54 |
-
const handleEvent = (data: EventData) => {
|
| 55 |
-
const { action, status } = data;
|
| 56 |
-
if (status === "finished" || status === "skipped") { localStorage.setItem(LS_GLOBAL, "true"); setRun(false); }
|
| 57 |
-
if (action === "close" || action === "skip") setRun(false);
|
| 58 |
-
};
|
| 59 |
-
|
| 60 |
return (
|
| 61 |
-
<
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
/>
|
| 65 |
);
|
| 66 |
}
|
| 67 |
|
|
@@ -78,32 +31,10 @@ interface PageTourProps {
|
|
| 78 |
}
|
| 79 |
|
| 80 |
export function PageTour({ storageKey, steps, autoDelay }: PageTourProps) {
|
| 81 |
-
const [run, setRun] = useState(false);
|
| 82 |
-
|
| 83 |
-
useEffect(() => {
|
| 84 |
-
const onTrigger = () => { localStorage.removeItem(storageKey); setRun(true); };
|
| 85 |
-
window.addEventListener(storageKey, onTrigger);
|
| 86 |
-
|
| 87 |
-
const completed = localStorage.getItem(storageKey);
|
| 88 |
-
if (!completed && autoDelay !== undefined) {
|
| 89 |
-
const timer = setTimeout(() => setRun(true), autoDelay);
|
| 90 |
-
return () => { clearTimeout(timer); window.removeEventListener(storageKey, onTrigger); };
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
return () => window.removeEventListener(storageKey, onTrigger);
|
| 94 |
-
}, []);
|
| 95 |
-
|
| 96 |
-
const handleEvent = (data: EventData) => {
|
| 97 |
-
const { action, status } = data;
|
| 98 |
-
if (status === "finished" || status === "skipped") { localStorage.setItem(storageKey, "true"); setRun(false); }
|
| 99 |
-
if (action === "close" || action === "skip") setRun(false);
|
| 100 |
-
};
|
| 101 |
-
|
| 102 |
return (
|
| 103 |
-
<
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
/>
|
| 107 |
);
|
| 108 |
}
|
| 109 |
|
|
|
|
| 1 |
+
import { Suspense, lazy } from "react";
|
|
|
|
| 2 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
+
import type { Step } from "react-joyride";
|
| 4 |
|
| 5 |
+
const TourGuideImpl = lazy(() => import("./TourGuideImpl").then((m) => ({ default: m.TourGuideImpl })));
|
| 6 |
+
const PageTourImpl = lazy(() => import("./TourGuideImpl").then((m) => ({ default: m.PageTourImpl })));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
export type { Step };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const LS_GLOBAL = "labas-tour-completed";
|
| 11 |
const TRIGGER_GLOBAL = "labas-tour-trigger";
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
export function TourGuide() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return (
|
| 15 |
+
<Suspense fallback={null}>
|
| 16 |
+
<TourGuideImpl />
|
| 17 |
+
</Suspense>
|
|
|
|
| 18 |
);
|
| 19 |
}
|
| 20 |
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
export function PageTour({ storageKey, steps, autoDelay }: PageTourProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return (
|
| 35 |
+
<Suspense fallback={null}>
|
| 36 |
+
<PageTourImpl storageKey={storageKey} steps={steps} autoDelay={autoDelay} />
|
| 37 |
+
</Suspense>
|
|
|
|
| 38 |
);
|
| 39 |
}
|
| 40 |
|
apps/web/src/components/TourGuideImpl.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, type CSSProperties } from "react";
|
| 2 |
+
import { Joyride, type Step, type EventData } from "react-joyride";
|
| 3 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 4 |
+
|
| 5 |
+
const joyrideOptions = {
|
| 6 |
+
primaryColor: "var(--matcha-600)",
|
| 7 |
+
textColor: "var(--clay-black)",
|
| 8 |
+
backgroundColor: "var(--pure-white)",
|
| 9 |
+
arrowColor: "var(--pure-white)",
|
| 10 |
+
overlayColor: "rgba(0,0,0,0.4)",
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
const joyrideStyles: Record<string, CSSProperties> = {
|
| 14 |
+
buttonBack: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 },
|
| 15 |
+
buttonSkip: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 },
|
| 16 |
+
tooltipContainer: { textAlign: "left" },
|
| 17 |
+
tooltipContent: { fontSize: "14px", lineHeight: "1.6", padding: "8px 0" },
|
| 18 |
+
tooltipTitle: { fontSize: "18px", fontWeight: 700, fontFamily: "Manrope, sans-serif" },
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const joyrideLocale = { back: "Kembali", close: "Tutup", last: "Selesai", next: "Lanjut", skip: "Lewati" };
|
| 22 |
+
|
| 23 |
+
export type { Step, EventData };
|
| 24 |
+
|
| 25 |
+
// ββ Global site tour ββ
|
| 26 |
+
|
| 27 |
+
const LS_GLOBAL = "labas-tour-completed";
|
| 28 |
+
const TRIGGER_GLOBAL = "labas-tour-trigger";
|
| 29 |
+
|
| 30 |
+
const globalSteps: Step[] = [
|
| 31 |
+
{ target: "body", placement: "center", title: "Selamat Datang di Labas!", content: "Platform latihan ujian bahasa berbasis AI. Yuk, kita lihat fitur-fitur utamanya!", hideOverlay: true },
|
| 32 |
+
{ target: "[data-tour='dashboard-stats']", title: "Ringkasan Aktivitas", content: "Pantau jumlah latihan, waktu belajar, soal terjawab, dan akurasi kamu di sini.", spotlightPadding: 8 },
|
| 33 |
+
{ target: "[data-tour='nav-generate']", title: "AI Lab β Generate Soal", content: "Buat soal latihan sendiri pakai AI. Pilih jenis ujian, section, format, dan topik.", spotlightPadding: 4 },
|
| 34 |
+
{ target: "[data-tour='nav-bank']", title: "Bank Soal β Buat Paket", content: "Atur soal-soal kamu jadi paket latihan dari bank soal.", spotlightPadding: 4 },
|
| 35 |
+
{ target: "[data-tour='nav-packages']", title: "Paket Soal β Mulai Latihan", content: "Temukan paket soal dari komunitas atau buatan sendiri.", spotlightPadding: 4 },
|
| 36 |
+
{ target: "[data-tour='nav-analytics']", title: "Analytics β Evaluasi", content: "Lihat analitik mendalam: skor, tren, dan rekomendasi belajar.", spotlightPadding: 4 },
|
| 37 |
+
{ target: "body", placement: "center", title: "Siap Belajar?", content: "Setiap halaman punya panduan sendiri. Cari tombol ? di pojok kanan bawah halaman!", hideOverlay: true },
|
| 38 |
+
];
|
| 39 |
+
|
| 40 |
+
export function TourGuideImpl() {
|
| 41 |
+
const [run, setRun] = useState(false);
|
| 42 |
+
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
const onTrigger = () => { localStorage.removeItem(LS_GLOBAL); setRun(true); };
|
| 45 |
+
window.addEventListener(TRIGGER_GLOBAL, onTrigger);
|
| 46 |
+
|
| 47 |
+
const completed = localStorage.getItem(LS_GLOBAL);
|
| 48 |
+
if (!completed) {
|
| 49 |
+
const timer = setTimeout(() => setRun(true), 800);
|
| 50 |
+
return () => { clearTimeout(timer); window.removeEventListener(TRIGGER_GLOBAL, onTrigger); };
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return () => window.removeEventListener(TRIGGER_GLOBAL, onTrigger);
|
| 54 |
+
}, []);
|
| 55 |
+
|
| 56 |
+
const handleEvent = (data: EventData) => {
|
| 57 |
+
const { action, status } = data;
|
| 58 |
+
if (status === "finished" || status === "skipped") { localStorage.setItem(LS_GLOBAL, "true"); setRun(false); }
|
| 59 |
+
if (action === "close" || action === "skip") setRun(false);
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<Joyride steps={globalSteps} run={run} continuous
|
| 64 |
+
options={{ ...joyrideOptions, showProgress: true, buttons: ["back", "primary", "skip"] }}
|
| 65 |
+
locale={joyrideLocale} styles={joyrideStyles} onEvent={handleEvent}
|
| 66 |
+
/>
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// ββ Page-specific tour ββ
|
| 71 |
+
|
| 72 |
+
interface PageTourProps {
|
| 73 |
+
storageKey: string;
|
| 74 |
+
steps: Step[];
|
| 75 |
+
autoDelay?: number;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export function PageTourImpl({ storageKey, steps, autoDelay }: PageTourProps) {
|
| 79 |
+
const [run, setRun] = useState(false);
|
| 80 |
+
|
| 81 |
+
useEffect(() => {
|
| 82 |
+
const onTrigger = () => { localStorage.removeItem(storageKey); setRun(true); };
|
| 83 |
+
window.addEventListener(storageKey, onTrigger);
|
| 84 |
+
|
| 85 |
+
const completed = localStorage.getItem(storageKey);
|
| 86 |
+
if (!completed && autoDelay !== undefined) {
|
| 87 |
+
const timer = setTimeout(() => setRun(true), autoDelay);
|
| 88 |
+
return () => { clearTimeout(timer); window.removeEventListener(storageKey, onTrigger); };
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return () => window.removeEventListener(storageKey, onTrigger);
|
| 92 |
+
}, []);
|
| 93 |
+
|
| 94 |
+
const handleEvent = (data: EventData) => {
|
| 95 |
+
const { action, status } = data;
|
| 96 |
+
if (status === "finished" || status === "skipped") { localStorage.setItem(storageKey, "true"); setRun(false); }
|
| 97 |
+
if (action === "close" || action === "skip") setRun(false);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
<Joyride steps={steps} run={run} continuous
|
| 102 |
+
options={{ ...joyrideOptions, showProgress: true, buttons: ["back", "primary", "skip"] }}
|
| 103 |
+
locale={joyrideLocale} styles={joyrideStyles} onEvent={handleEvent}
|
| 104 |
+
/>
|
| 105 |
+
);
|
| 106 |
+
}
|
apps/web/src/components/analytics/BreakdownCharts.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import { formatLabel } from "@/lib/format";
|
|
| 13 |
import { formatTime } from "@/lib/time";
|
| 14 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 15 |
|
| 16 |
-
interface ExamTypeItem {
|
| 17 |
examTypeId: string;
|
| 18 |
examTypeName: string;
|
| 19 |
accuracyPct: number;
|
|
@@ -22,7 +22,7 @@ interface ExamTypeItem {
|
|
| 22 |
attempts: number;
|
| 23 |
}
|
| 24 |
|
| 25 |
-
interface SectionTypeItem {
|
| 26 |
sectionTypeId: string;
|
| 27 |
sectionTypeName: string;
|
| 28 |
accuracyPct: number;
|
|
@@ -31,7 +31,7 @@ interface SectionTypeItem {
|
|
| 31 |
attempts: number;
|
| 32 |
}
|
| 33 |
|
| 34 |
-
interface FormatItem {
|
| 35 |
format: string;
|
| 36 |
accuracyPct: number;
|
| 37 |
avgTimeSpentSec: number;
|
|
|
|
| 13 |
import { formatTime } from "@/lib/time";
|
| 14 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 15 |
|
| 16 |
+
export interface ExamTypeItem {
|
| 17 |
examTypeId: string;
|
| 18 |
examTypeName: string;
|
| 19 |
accuracyPct: number;
|
|
|
|
| 22 |
attempts: number;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
export interface SectionTypeItem {
|
| 26 |
sectionTypeId: string;
|
| 27 |
sectionTypeName: string;
|
| 28 |
accuracyPct: number;
|
|
|
|
| 31 |
attempts: number;
|
| 32 |
}
|
| 33 |
|
| 34 |
+
export interface FormatItem {
|
| 35 |
format: string;
|
| 36 |
accuracyPct: number;
|
| 37 |
avgTimeSpentSec: number;
|
apps/web/src/components/analytics/ChartsBundle.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ScoreTrendChart } from "./ScoreTrendChart";
|
| 2 |
+
import { BreakdownCharts, type ExamTypeItem, type SectionTypeItem, type FormatItem } from "./BreakdownCharts";
|
| 3 |
+
import { TimeAnalyticsPanel, type SectionTimeItem, type FormatTimeItem, type TimeTrendItem } from "./TimeAnalyticsPanel";
|
| 4 |
+
|
| 5 |
+
interface ChartsBundleProps {
|
| 6 |
+
trend: { date: string; attempts: number; avgScorePct: number }[] | undefined;
|
| 7 |
+
byExamType: ExamTypeItem[] | undefined;
|
| 8 |
+
bySectionType: SectionTypeItem[] | undefined;
|
| 9 |
+
byFormat: FormatItem[] | undefined;
|
| 10 |
+
sectionTime: SectionTimeItem[] | undefined;
|
| 11 |
+
formatTimeData: FormatTimeItem[] | undefined;
|
| 12 |
+
timeTrend: TimeTrendItem[] | undefined;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function ChartsBundle({
|
| 16 |
+
trend,
|
| 17 |
+
byExamType,
|
| 18 |
+
bySectionType,
|
| 19 |
+
byFormat,
|
| 20 |
+
sectionTime,
|
| 21 |
+
formatTimeData,
|
| 22 |
+
timeTrend,
|
| 23 |
+
}: ChartsBundleProps) {
|
| 24 |
+
return (
|
| 25 |
+
<>
|
| 26 |
+
<div className="mb-8">
|
| 27 |
+
<ScoreTrendChart data={trend} />
|
| 28 |
+
</div>
|
| 29 |
+
<div className="mb-8">
|
| 30 |
+
<BreakdownCharts
|
| 31 |
+
byExamType={byExamType}
|
| 32 |
+
bySectionType={bySectionType}
|
| 33 |
+
byFormat={byFormat}
|
| 34 |
+
/>
|
| 35 |
+
</div>
|
| 36 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 37 |
+
<TimeAnalyticsPanel
|
| 38 |
+
sectionTime={sectionTime}
|
| 39 |
+
formatTimeData={formatTimeData}
|
| 40 |
+
timeTrend={timeTrend}
|
| 41 |
+
/>
|
| 42 |
+
</div>
|
| 43 |
+
</>
|
| 44 |
+
);
|
| 45 |
+
}
|
apps/web/src/components/analytics/TimeAnalyticsPanel.tsx
CHANGED
|
@@ -11,19 +11,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/c
|
|
| 11 |
import { formatTime } from "@/lib/time";
|
| 12 |
import { formatLabel } from "@/lib/format";
|
| 13 |
|
| 14 |
-
interface SectionTimeItem {
|
| 15 |
sectionTypeName: string;
|
| 16 |
avgTimeSpentSec: number;
|
| 17 |
totalTimeSpentSec: number;
|
| 18 |
}
|
| 19 |
|
| 20 |
-
interface FormatTimeItem {
|
| 21 |
format: string;
|
| 22 |
avgTimeSpentSec: number;
|
| 23 |
totalTimeSpentSec: number;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
interface TimeTrendItem {
|
| 27 |
date: string;
|
| 28 |
avgTimeSpentSec: number;
|
| 29 |
}
|
|
|
|
| 11 |
import { formatTime } from "@/lib/time";
|
| 12 |
import { formatLabel } from "@/lib/format";
|
| 13 |
|
| 14 |
+
export interface SectionTimeItem {
|
| 15 |
sectionTypeName: string;
|
| 16 |
avgTimeSpentSec: number;
|
| 17 |
totalTimeSpentSec: number;
|
| 18 |
}
|
| 19 |
|
| 20 |
+
export interface FormatTimeItem {
|
| 21 |
format: string;
|
| 22 |
avgTimeSpentSec: number;
|
| 23 |
totalTimeSpentSec: number;
|
| 24 |
}
|
| 25 |
|
| 26 |
+
export interface TimeTrendItem {
|
| 27 |
date: string;
|
| 28 |
avgTimeSpentSec: number;
|
| 29 |
}
|
apps/web/src/components/attempt/OptionDisplay.tsx
CHANGED
|
@@ -2,18 +2,28 @@ import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
| 2 |
|
| 3 |
import { MCQ_FORMATS, McqFormat } from "@/lib/question-formats";
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
interface OptionDisplayProps {
|
| 6 |
format: string;
|
| 7 |
-
options:
|
| 8 |
correctAnswer: string;
|
| 9 |
userAnswer: string;
|
| 10 |
}
|
| 11 |
|
| 12 |
export function OptionDisplay({ format, options, correctAnswer, userAnswer }: OptionDisplayProps) {
|
| 13 |
-
if (!options || options.length === 0) return null;
|
| 14 |
|
| 15 |
if (format === "matching_pairs") {
|
| 16 |
-
return <MatchingPairsOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 17 |
}
|
| 18 |
|
| 19 |
if (format === "true_false_not_given") {
|
|
@@ -24,8 +34,8 @@ export function OptionDisplay({ format, options, correctAnswer, userAnswer }: Op
|
|
| 24 |
return <TriStateOptions choices={["YES", "NO", "NOT_GIVEN"]} labels={{ YES: "Yes", NO: "No", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 25 |
}
|
| 26 |
|
| 27 |
-
if (MCQ_FORMATS.includes(format
|
| 28 |
-
return <McqOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 29 |
}
|
| 30 |
|
| 31 |
return null;
|
|
@@ -114,7 +124,7 @@ function TriStateOptions({ choices, labels, correctAnswer, userAnswer }: { choic
|
|
| 114 |
);
|
| 115 |
}
|
| 116 |
|
| 117 |
-
function MatchingPairsOptions({ options, correctAnswer, userAnswer }: { options:
|
| 118 |
const parseMapping = (s: string): Map<string, string> => {
|
| 119 |
const map = new Map();
|
| 120 |
if (!s) return map;
|
|
|
|
| 2 |
|
| 3 |
import { MCQ_FORMATS, McqFormat } from "@/lib/question-formats";
|
| 4 |
|
| 5 |
+
interface McqOptionItem {
|
| 6 |
+
key: string;
|
| 7 |
+
text: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
interface MatchingOptionItem {
|
| 11 |
+
left: string;
|
| 12 |
+
right?: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
interface OptionDisplayProps {
|
| 16 |
format: string;
|
| 17 |
+
options: unknown;
|
| 18 |
correctAnswer: string;
|
| 19 |
userAnswer: string;
|
| 20 |
}
|
| 21 |
|
| 22 |
export function OptionDisplay({ format, options, correctAnswer, userAnswer }: OptionDisplayProps) {
|
| 23 |
+
if (!Array.isArray(options) || options.length === 0) return null;
|
| 24 |
|
| 25 |
if (format === "matching_pairs") {
|
| 26 |
+
return <MatchingPairsOptions options={options as MatchingOptionItem[]} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 27 |
}
|
| 28 |
|
| 29 |
if (format === "true_false_not_given") {
|
|
|
|
| 34 |
return <TriStateOptions choices={["YES", "NO", "NOT_GIVEN"]} labels={{ YES: "Yes", NO: "No", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
if ((MCQ_FORMATS as readonly string[]).includes(format)) {
|
| 38 |
+
return <McqOptions options={options as McqOptionItem[]} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
|
| 39 |
}
|
| 40 |
|
| 41 |
return null;
|
|
|
|
| 124 |
);
|
| 125 |
}
|
| 126 |
|
| 127 |
+
function MatchingPairsOptions({ options, correctAnswer, userAnswer }: { options: MatchingOptionItem[]; correctAnswer: string; userAnswer: string }) {
|
| 128 |
const parseMapping = (s: string): Map<string, string> => {
|
| 129 |
const map = new Map();
|
| 130 |
if (!s) return map;
|
apps/web/src/components/attempt/QuestionReviewCard.tsx
CHANGED
|
@@ -5,13 +5,14 @@ import { Button } from "@labas/ui/components/button";
|
|
| 5 |
import { Input } from "@labas/ui/components/input";
|
| 6 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 7 |
import { formatTime } from "@/lib/time";
|
|
|
|
| 8 |
import { OptionDisplay } from "./OptionDisplay";
|
| 9 |
|
| 10 |
interface QuestionReviewCardProps {
|
| 11 |
-
q:
|
| 12 |
secIdx: number;
|
| 13 |
qIdx: number;
|
| 14 |
-
ans:
|
| 15 |
userId?: string;
|
| 16 |
isExpanded: boolean;
|
| 17 |
onToggleExpand: () => void;
|
|
@@ -97,7 +98,7 @@ export function QuestionReviewCard({
|
|
| 97 |
</p>
|
| 98 |
<div className="flex items-center gap-2 mt-1">
|
| 99 |
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 100 |
-
{q.format.replace(/_/g, " ")}
|
| 101 |
</span>
|
| 102 |
{ans?.timeSpentSec != null && (
|
| 103 |
<span className="text-[10px] text-[var(--warm-silver)] flex items-center gap-0.5">
|
|
@@ -135,9 +136,9 @@ export function QuestionReviewCard({
|
|
| 135 |
{/* Interactive Option Display */}
|
| 136 |
{hasOptions && (
|
| 137 |
<OptionDisplay
|
| 138 |
-
format={q.format}
|
| 139 |
options={q.options}
|
| 140 |
-
correctAnswer={q.correctAnswer}
|
| 141 |
userAnswer={userAnswer}
|
| 142 |
/>
|
| 143 |
)}
|
|
|
|
| 5 |
import { Input } from "@labas/ui/components/input";
|
| 6 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 7 |
import { formatTime } from "@/lib/time";
|
| 8 |
+
import type { Question } from "@/lib/types";
|
| 9 |
import { OptionDisplay } from "./OptionDisplay";
|
| 10 |
|
| 11 |
interface QuestionReviewCardProps {
|
| 12 |
+
q: Question;
|
| 13 |
secIdx: number;
|
| 14 |
qIdx: number;
|
| 15 |
+
ans: { questionId: string; userAnswer?: string; answer?: string; isCorrect?: boolean | null; partialScore?: number | null; timeSpentSec?: number } | null;
|
| 16 |
userId?: string;
|
| 17 |
isExpanded: boolean;
|
| 18 |
onToggleExpand: () => void;
|
|
|
|
| 98 |
</p>
|
| 99 |
<div className="flex items-center gap-2 mt-1">
|
| 100 |
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 101 |
+
{(q.format ?? "").replace(/_/g, " ")}
|
| 102 |
</span>
|
| 103 |
{ans?.timeSpentSec != null && (
|
| 104 |
<span className="text-[10px] text-[var(--warm-silver)] flex items-center gap-0.5">
|
|
|
|
| 136 |
{/* Interactive Option Display */}
|
| 137 |
{hasOptions && (
|
| 138 |
<OptionDisplay
|
| 139 |
+
format={q.format ?? ""}
|
| 140 |
options={q.options}
|
| 141 |
+
correctAnswer={q.correctAnswer ?? ""}
|
| 142 |
userAnswer={userAnswer}
|
| 143 |
/>
|
| 144 |
)}
|
apps/web/src/components/bank/AutoBundleModal.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
import { Button } from "@labas/ui/components/button";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
|
| 6 |
export function AutoBundleModal({
|
|
@@ -32,24 +38,13 @@ export function AutoBundleModal({
|
|
| 32 |
const [sortOrder, setSortOrder] = useState<"random" | "difficulty">("random");
|
| 33 |
|
| 34 |
return (
|
| 35 |
-
<
|
| 36 |
-
className="
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
}}
|
| 40 |
-
>
|
| 41 |
-
<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">
|
| 42 |
-
<div className="flex items-center justify-between mb-6">
|
| 43 |
-
<h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
| 44 |
Auto Bundle
|
| 45 |
-
</
|
| 46 |
-
|
| 47 |
-
onClick={onClose}
|
| 48 |
-
className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
|
| 49 |
-
>
|
| 50 |
-
<MaterialIcon name="close" className="text-[var(--clay-black)]" />
|
| 51 |
-
</button>
|
| 52 |
-
</div>
|
| 53 |
|
| 54 |
<div className="flex items-center gap-2 mb-4">
|
| 55 |
<span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
|
|
@@ -180,7 +175,7 @@ export function AutoBundleModal({
|
|
| 180 |
{isPending ? "Membuat..." : "Buat Paket"}
|
| 181 |
</Button>
|
| 182 |
</div>
|
| 183 |
-
</
|
| 184 |
-
</
|
| 185 |
);
|
| 186 |
}
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
import { Button } from "@labas/ui/components/button";
|
| 4 |
+
import {
|
| 5 |
+
Dialog,
|
| 6 |
+
DialogContent,
|
| 7 |
+
DialogHeader,
|
| 8 |
+
DialogTitle,
|
| 9 |
+
} from "@labas/ui/components/dialog";
|
| 10 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 11 |
|
| 12 |
export function AutoBundleModal({
|
|
|
|
| 38 |
const [sortOrder, setSortOrder] = useState<"random" | "difficulty">("random");
|
| 39 |
|
| 40 |
return (
|
| 41 |
+
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
| 42 |
+
<DialogContent className="max-w-lg">
|
| 43 |
+
<DialogHeader>
|
| 44 |
+
<DialogTitle className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
Auto Bundle
|
| 46 |
+
</DialogTitle>
|
| 47 |
+
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
<div className="flex items-center gap-2 mb-4">
|
| 50 |
<span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
|
|
|
|
| 175 |
{isPending ? "Membuat..." : "Buat Paket"}
|
| 176 |
</Button>
|
| 177 |
</div>
|
| 178 |
+
</DialogContent>
|
| 179 |
+
</Dialog>
|
| 180 |
);
|
| 181 |
}
|
apps/web/src/components/bank/BundleSidebar.tsx
CHANGED
|
@@ -7,9 +7,9 @@ interface BundleItem {
|
|
| 7 |
id: string;
|
| 8 |
questionText?: string;
|
| 9 |
title?: string;
|
| 10 |
-
examTypeName?: string;
|
| 11 |
format?: string;
|
| 12 |
-
sectionTypeName?: string;
|
| 13 |
}
|
| 14 |
|
| 15 |
interface BundleSidebarProps {
|
|
@@ -82,7 +82,7 @@ export function BundleSidebar({
|
|
| 82 |
Belum ada yang dipilih
|
| 83 |
</div>
|
| 84 |
) : (
|
| 85 |
-
activeBundle.map((item
|
| 86 |
const isQ = mode === "soal";
|
| 87 |
return (
|
| 88 |
<div
|
|
@@ -93,17 +93,18 @@ export function BundleSidebar({
|
|
| 93 |
<MaterialIcon name={isQ ? "quiz" : "folder_open"} className="text-sm" />
|
| 94 |
</div>
|
| 95 |
<div className="flex-1 min-w-0">
|
| 96 |
-
<
|
| 97 |
{isQ ? item.questionText : item.title}
|
| 98 |
-
</
|
| 99 |
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 100 |
{isQ
|
| 101 |
-
? `${item.examTypeName} \u00B7 ${formatLabel(item.format)}`
|
| 102 |
: `${item.examTypeName} \u00B7 ${item.sectionTypeName}`}
|
| 103 |
</p>
|
| 104 |
</div>
|
| 105 |
<button
|
| 106 |
onClick={() => onRemoveFromBundle(item.id, isQ ? "question" : "section")}
|
|
|
|
| 107 |
className="text-[var(--warm-silver)] hover:text-[var(--pomegranate-400)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0 cursor-pointer"
|
| 108 |
>
|
| 109 |
<MaterialIcon name="delete" className="text-sm" />
|
|
|
|
| 7 |
id: string;
|
| 8 |
questionText?: string;
|
| 9 |
title?: string;
|
| 10 |
+
examTypeName?: string | null;
|
| 11 |
format?: string;
|
| 12 |
+
sectionTypeName?: string | null;
|
| 13 |
}
|
| 14 |
|
| 15 |
interface BundleSidebarProps {
|
|
|
|
| 82 |
Belum ada yang dipilih
|
| 83 |
</div>
|
| 84 |
) : (
|
| 85 |
+
activeBundle.map((item, idx: number) => {
|
| 86 |
const isQ = mode === "soal";
|
| 87 |
return (
|
| 88 |
<div
|
|
|
|
| 93 |
<MaterialIcon name={isQ ? "quiz" : "folder_open"} className="text-sm" />
|
| 94 |
</div>
|
| 95 |
<div className="flex-1 min-w-0">
|
| 96 |
+
<h4 className="text-sm font-bold text-[var(--clay-black)] truncate">
|
| 97 |
{isQ ? item.questionText : item.title}
|
| 98 |
+
</h4>
|
| 99 |
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 100 |
{isQ
|
| 101 |
+
? `${item.examTypeName} \u00B7 ${formatLabel(item.format ?? "")}`
|
| 102 |
: `${item.examTypeName} \u00B7 ${item.sectionTypeName}`}
|
| 103 |
</p>
|
| 104 |
</div>
|
| 105 |
<button
|
| 106 |
onClick={() => onRemoveFromBundle(item.id, isQ ? "question" : "section")}
|
| 107 |
+
aria-label={`Hapus ${isQ ? "soal" : "section"} dari bundle`}
|
| 108 |
className="text-[var(--warm-silver)] hover:text-[var(--pomegranate-400)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0 cursor-pointer"
|
| 109 |
>
|
| 110 |
<MaterialIcon name="delete" className="text-sm" />
|
apps/web/src/components/bank/CreatePackageModal.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
import { Button } from "@labas/ui/components/button";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
|
| 6 |
export function CreatePackageModal({
|
|
@@ -18,30 +24,18 @@ export function CreatePackageModal({
|
|
| 18 |
examTypeName: string;
|
| 19 |
lowCount: boolean;
|
| 20 |
}) {
|
| 21 |
-
const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
|
| 22 |
const [title, setTitle] = useState(`${examTypeName} Bundle β ${selectedCount} Soal`);
|
| 23 |
const [description, setDescription] = useState("");
|
| 24 |
const [isPublic, setIsPublic] = useState(false);
|
| 25 |
|
| 26 |
return (
|
| 27 |
-
<
|
| 28 |
-
className="
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
}}
|
| 32 |
-
>
|
| 33 |
-
<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">
|
| 34 |
-
<div className="flex items-center justify-between mb-6">
|
| 35 |
-
<h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
| 36 |
Buat Paket Soal
|
| 37 |
-
</
|
| 38 |
-
|
| 39 |
-
onClick={onClose}
|
| 40 |
-
className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
|
| 41 |
-
>
|
| 42 |
-
<MaterialIcon name="close" className="text-[var(--clay-black)]" />
|
| 43 |
-
</button>
|
| 44 |
-
</div>
|
| 45 |
|
| 46 |
<div className="flex items-center gap-2 mb-4">
|
| 47 |
<span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
|
|
@@ -115,7 +109,7 @@ export function CreatePackageModal({
|
|
| 115 |
{isPending ? "Membuat..." : "Buat Paket"}
|
| 116 |
</Button>
|
| 117 |
</div>
|
| 118 |
-
</
|
| 119 |
-
</
|
| 120 |
);
|
| 121 |
}
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
import { Button } from "@labas/ui/components/button";
|
| 4 |
+
import {
|
| 5 |
+
Dialog,
|
| 6 |
+
DialogContent,
|
| 7 |
+
DialogHeader,
|
| 8 |
+
DialogTitle,
|
| 9 |
+
} from "@labas/ui/components/dialog";
|
| 10 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 11 |
|
| 12 |
export function CreatePackageModal({
|
|
|
|
| 24 |
examTypeName: string;
|
| 25 |
lowCount: boolean;
|
| 26 |
}) {
|
|
|
|
| 27 |
const [title, setTitle] = useState(`${examTypeName} Bundle β ${selectedCount} Soal`);
|
| 28 |
const [description, setDescription] = useState("");
|
| 29 |
const [isPublic, setIsPublic] = useState(false);
|
| 30 |
|
| 31 |
return (
|
| 32 |
+
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
| 33 |
+
<DialogContent className="max-w-lg">
|
| 34 |
+
<DialogHeader>
|
| 35 |
+
<DialogTitle className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
Buat Paket Soal
|
| 37 |
+
</DialogTitle>
|
| 38 |
+
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
<div className="flex items-center gap-2 mb-4">
|
| 41 |
<span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
|
|
|
|
| 109 |
{isPending ? "Membuat..." : "Buat Paket"}
|
| 110 |
</Button>
|
| 111 |
</div>
|
| 112 |
+
</DialogContent>
|
| 113 |
+
</Dialog>
|
| 114 |
);
|
| 115 |
}
|
apps/web/src/components/bank/FilterBar.tsx
CHANGED
|
@@ -59,7 +59,12 @@ export function FilterBar({
|
|
| 59 |
<div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-4 pb-3 space-y-3">
|
| 60 |
{/* ββ Tier 1: Mode tabs ββ */}
|
| 61 |
<div className="flex items-center justify-between gap-3">
|
| 62 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
<ModeButton active={mode === "soal"} onClick={() => onSetMode("soal")}>
|
| 64 |
Dari Soal
|
| 65 |
</ModeButton>
|
|
@@ -68,7 +73,12 @@ export function FilterBar({
|
|
| 68 |
</ModeButton>
|
| 69 |
</div>
|
| 70 |
{mode === "soal" && (
|
| 71 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
<TabButton active={tab === "mine"} onClick={() => onSetTab("mine")}>
|
| 73 |
Soal Saya
|
| 74 |
</TabButton>
|
|
@@ -80,7 +90,12 @@ export function FilterBar({
|
|
| 80 |
</div>
|
| 81 |
|
| 82 |
{/* ββ Tier 2: Exam type chips ββ */}
|
| 83 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
<ChipButton
|
| 85 |
active={examType === ""}
|
| 86 |
onClick={() => onSetExamType("")}
|
|
@@ -110,7 +125,12 @@ export function FilterBar({
|
|
| 110 |
|
| 111 |
{/* ββ Visibility sub-filter (only in "mine" tab) ββ */}
|
| 112 |
{mode === "soal" && tab === "mine" && onSetVisibility && (
|
| 113 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
<VisChipButton
|
| 115 |
active={visibility === "all"}
|
| 116 |
onClick={() => onSetVisibility("all")}
|
|
@@ -143,6 +163,7 @@ export function FilterBar({
|
|
| 143 |
placeholder={mode === "soal" ? "Cari soal..." : "Cari section..."}
|
| 144 |
value={searchText}
|
| 145 |
onChange={(e) => onSetSearch(e.target.value)}
|
|
|
|
| 146 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 147 |
/>
|
| 148 |
</div>
|
|
@@ -169,6 +190,7 @@ export function FilterBar({
|
|
| 169 |
<Button
|
| 170 |
variant="outline"
|
| 171 |
onClick={onClearFilters}
|
|
|
|
| 172 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] h-11 clay-hover cursor-pointer"
|
| 173 |
>
|
| 174 |
<MaterialIcon name="filter_alt_off" />
|
|
@@ -242,9 +264,32 @@ export function FilterBar({
|
|
| 242 |
);
|
| 243 |
}
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
function ModeButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 246 |
return (
|
| 247 |
<button
|
|
|
|
|
|
|
|
|
|
| 248 |
onClick={onClick}
|
| 249 |
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all cursor-pointer ${
|
| 250 |
active
|
|
@@ -260,6 +305,9 @@ function ModeButton({ active, onClick, children }: { active: boolean; onClick: (
|
|
| 260 |
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 261 |
return (
|
| 262 |
<button
|
|
|
|
|
|
|
|
|
|
| 263 |
onClick={onClick}
|
| 264 |
className={`px-3 py-1.5 rounded-[var(--radius-lg)] text-xs font-semibold transition-all cursor-pointer ${
|
| 265 |
active
|
|
@@ -275,6 +323,9 @@ function TabButton({ active, onClick, children }: { active: boolean; onClick: ()
|
|
| 275 |
function VisChipButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 276 |
return (
|
| 277 |
<button
|
|
|
|
|
|
|
|
|
|
| 278 |
onClick={onClick}
|
| 279 |
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
|
| 280 |
active
|
|
@@ -290,6 +341,9 @@ function VisChipButton({ active, onClick, children }: { active: boolean; onClick
|
|
| 290 |
function ChipButton({ active, onClick, children, disabled }: { active: boolean; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {
|
| 291 |
return (
|
| 292 |
<button
|
|
|
|
|
|
|
|
|
|
| 293 |
onClick={onClick}
|
| 294 |
disabled={disabled}
|
| 295 |
className={`px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-all ${
|
|
|
|
| 59 |
<div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-4 pb-3 space-y-3">
|
| 60 |
{/* ββ Tier 1: Mode tabs ββ */}
|
| 61 |
<div className="flex items-center justify-between gap-3">
|
| 62 |
+
<div
|
| 63 |
+
className="flex gap-2"
|
| 64 |
+
role="tablist"
|
| 65 |
+
aria-label="Mode bank soal"
|
| 66 |
+
onKeyDown={(e) => handleRovingKeyDown(e, "tab")}
|
| 67 |
+
>
|
| 68 |
<ModeButton active={mode === "soal"} onClick={() => onSetMode("soal")}>
|
| 69 |
Dari Soal
|
| 70 |
</ModeButton>
|
|
|
|
| 73 |
</ModeButton>
|
| 74 |
</div>
|
| 75 |
{mode === "soal" && (
|
| 76 |
+
<div
|
| 77 |
+
className="flex gap-2"
|
| 78 |
+
role="tablist"
|
| 79 |
+
aria-label="Sumber soal"
|
| 80 |
+
onKeyDown={(e) => handleRovingKeyDown(e, "tab")}
|
| 81 |
+
>
|
| 82 |
<TabButton active={tab === "mine"} onClick={() => onSetTab("mine")}>
|
| 83 |
Soal Saya
|
| 84 |
</TabButton>
|
|
|
|
| 90 |
</div>
|
| 91 |
|
| 92 |
{/* ββ Tier 2: Exam type chips ββ */}
|
| 93 |
+
<div
|
| 94 |
+
className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
| 95 |
+
role="radiogroup"
|
| 96 |
+
aria-label="Jenis ujian"
|
| 97 |
+
onKeyDown={(e) => handleRovingKeyDown(e, "radio")}
|
| 98 |
+
>
|
| 99 |
<ChipButton
|
| 100 |
active={examType === ""}
|
| 101 |
onClick={() => onSetExamType("")}
|
|
|
|
| 125 |
|
| 126 |
{/* ββ Visibility sub-filter (only in "mine" tab) ββ */}
|
| 127 |
{mode === "soal" && tab === "mine" && onSetVisibility && (
|
| 128 |
+
<div
|
| 129 |
+
className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
| 130 |
+
role="radiogroup"
|
| 131 |
+
aria-label="Visibilitas soal"
|
| 132 |
+
onKeyDown={(e) => handleRovingKeyDown(e, "radio")}
|
| 133 |
+
>
|
| 134 |
<VisChipButton
|
| 135 |
active={visibility === "all"}
|
| 136 |
onClick={() => onSetVisibility("all")}
|
|
|
|
| 163 |
placeholder={mode === "soal" ? "Cari soal..." : "Cari section..."}
|
| 164 |
value={searchText}
|
| 165 |
onChange={(e) => onSetSearch(e.target.value)}
|
| 166 |
+
aria-label={mode === "soal" ? "Cari soal" : "Cari section"}
|
| 167 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 168 |
/>
|
| 169 |
</div>
|
|
|
|
| 190 |
<Button
|
| 191 |
variant="outline"
|
| 192 |
onClick={onClearFilters}
|
| 193 |
+
aria-label="Reset filter"
|
| 194 |
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] h-11 clay-hover cursor-pointer"
|
| 195 |
>
|
| 196 |
<MaterialIcon name="filter_alt_off" />
|
|
|
|
| 264 |
);
|
| 265 |
}
|
| 266 |
|
| 267 |
+
function handleRovingKeyDown(e: React.KeyboardEvent<HTMLDivElement>, role: string) {
|
| 268 |
+
const buttons = Array.from(e.currentTarget.querySelectorAll(`[role="${role}"]`)) as HTMLElement[];
|
| 269 |
+
const idx = buttons.findIndex((b) => b === document.activeElement);
|
| 270 |
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
| 271 |
+
e.preventDefault();
|
| 272 |
+
const next = buttons[(idx + 1) % buttons.length];
|
| 273 |
+
next?.focus();
|
| 274 |
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
| 275 |
+
e.preventDefault();
|
| 276 |
+
const prev = buttons[(idx - 1 + buttons.length) % buttons.length];
|
| 277 |
+
prev?.focus();
|
| 278 |
+
} else if (e.key === "Home") {
|
| 279 |
+
e.preventDefault();
|
| 280 |
+
buttons[0]?.focus();
|
| 281 |
+
} else if (e.key === "End") {
|
| 282 |
+
e.preventDefault();
|
| 283 |
+
buttons[buttons.length - 1]?.focus();
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
function ModeButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 288 |
return (
|
| 289 |
<button
|
| 290 |
+
role="tab"
|
| 291 |
+
aria-selected={active}
|
| 292 |
+
tabIndex={active ? 0 : -1}
|
| 293 |
onClick={onClick}
|
| 294 |
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all cursor-pointer ${
|
| 295 |
active
|
|
|
|
| 305 |
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 306 |
return (
|
| 307 |
<button
|
| 308 |
+
role="tab"
|
| 309 |
+
aria-selected={active}
|
| 310 |
+
tabIndex={active ? 0 : -1}
|
| 311 |
onClick={onClick}
|
| 312 |
className={`px-3 py-1.5 rounded-[var(--radius-lg)] text-xs font-semibold transition-all cursor-pointer ${
|
| 313 |
active
|
|
|
|
| 323 |
function VisChipButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 324 |
return (
|
| 325 |
<button
|
| 326 |
+
role="radio"
|
| 327 |
+
aria-checked={active}
|
| 328 |
+
tabIndex={active ? 0 : -1}
|
| 329 |
onClick={onClick}
|
| 330 |
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
|
| 331 |
active
|
|
|
|
| 341 |
function ChipButton({ active, onClick, children, disabled }: { active: boolean; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {
|
| 342 |
return (
|
| 343 |
<button
|
| 344 |
+
role="radio"
|
| 345 |
+
aria-checked={active}
|
| 346 |
+
tabIndex={active ? 0 : -1}
|
| 347 |
onClick={onClick}
|
| 348 |
disabled={disabled}
|
| 349 |
className={`px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-all ${
|
apps/web/src/components/bank/QuestionCard.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import { Button } from "@labas/ui/components/button";
|
| 2 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
import { formatLabel } from "@/lib/format";
|
|
|
|
| 4 |
|
| 5 |
interface QuestionCardProps {
|
| 6 |
-
q:
|
| 7 |
isFeatured?: boolean;
|
| 8 |
isInBundle: boolean;
|
| 9 |
disabled?: boolean;
|
|
@@ -99,7 +100,7 @@ export function QuestionCard({
|
|
| 99 |
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 100 |
<div className="flex gap-2">
|
| 101 |
<span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
|
| 102 |
-
{formatLabel(q.format)}
|
| 103 |
</span>
|
| 104 |
<span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
|
| 105 |
Lv.{q.difficulty}
|
|
@@ -128,6 +129,7 @@ export function QuestionCard({
|
|
| 128 |
</button>
|
| 129 |
<button
|
| 130 |
onClick={onDelete}
|
|
|
|
| 131 |
className="text-[var(--pomegranate-400)] hover:bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full transition-colors cursor-pointer"
|
| 132 |
>
|
| 133 |
<MaterialIcon name="delete" className="text-sm" />
|
|
|
|
| 1 |
import { Button } from "@labas/ui/components/button";
|
| 2 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
import { formatLabel } from "@/lib/format";
|
| 4 |
+
import type { Question } from "@/lib/types";
|
| 5 |
|
| 6 |
interface QuestionCardProps {
|
| 7 |
+
q: Question;
|
| 8 |
isFeatured?: boolean;
|
| 9 |
isInBundle: boolean;
|
| 10 |
disabled?: boolean;
|
|
|
|
| 100 |
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 101 |
<div className="flex gap-2">
|
| 102 |
<span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
|
| 103 |
+
{formatLabel(q.format ?? "")}
|
| 104 |
</span>
|
| 105 |
<span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
|
| 106 |
Lv.{q.difficulty}
|
|
|
|
| 129 |
</button>
|
| 130 |
<button
|
| 131 |
onClick={onDelete}
|
| 132 |
+
aria-label="Hapus soal"
|
| 133 |
className="text-[var(--pomegranate-400)] hover:bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full transition-colors cursor-pointer"
|
| 134 |
>
|
| 135 |
<MaterialIcon name="delete" className="text-sm" />
|
apps/web/src/components/bank/QuestionDetailModal.tsx
CHANGED
|
@@ -1,10 +1,17 @@
|
|
| 1 |
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 2 |
import { Button } from "@labas/ui/components/button";
|
| 3 |
import { Card, CardContent } from "@labas/ui/components/card";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { trpc } from "@/utils/trpc";
|
| 6 |
import { formatLabel } from "@/lib/format";
|
| 7 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
|
|
| 8 |
|
| 9 |
export function QuestionDetailModal({
|
| 10 |
question,
|
|
@@ -13,7 +20,7 @@ export function QuestionDetailModal({
|
|
| 13 |
onToggleSelect,
|
| 14 |
isSelectable,
|
| 15 |
}: {
|
| 16 |
-
question:
|
| 17 |
onClose: () => void;
|
| 18 |
isSelected: boolean;
|
| 19 |
onToggleSelect: () => void;
|
|
@@ -32,36 +39,26 @@ export function QuestionDetailModal({
|
|
| 32 |
});
|
| 33 |
|
| 34 |
return (
|
| 35 |
-
<
|
| 36 |
-
className="
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
</span>
|
| 54 |
-
<span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
|
| 55 |
-
Level {question.difficulty}
|
| 56 |
-
</span>
|
| 57 |
</div>
|
| 58 |
-
|
| 59 |
-
onClick={onClose}
|
| 60 |
-
className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
|
| 61 |
-
>
|
| 62 |
-
<MaterialIcon name="close" className="text-[var(--clay-black)]" />
|
| 63 |
-
</button>
|
| 64 |
-
</div>
|
| 65 |
|
| 66 |
{/* Passage */}
|
| 67 |
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
|
|
@@ -112,7 +109,7 @@ export function QuestionDetailModal({
|
|
| 112 |
<span className="font-semibold text-[var(--clay-black)]">
|
| 113 |
Jenis soal:{" "}
|
| 114 |
</span>
|
| 115 |
-
{formatLabel(question.format)}
|
| 116 |
</div>
|
| 117 |
)}
|
| 118 |
</CardContent>
|
|
@@ -139,6 +136,7 @@ export function QuestionDetailModal({
|
|
| 139 |
<button
|
| 140 |
key={star}
|
| 141 |
onClick={() => rateMutation.mutate({ questionId: question.id, score: star })}
|
|
|
|
| 142 |
className="transition-transform hover:scale-110"
|
| 143 |
>
|
| 144 |
<MaterialIcon
|
|
@@ -208,7 +206,7 @@ export function QuestionDetailModal({
|
|
| 208 |
Tutup
|
| 209 |
</Button>
|
| 210 |
</div>
|
| 211 |
-
</
|
| 212 |
-
</
|
| 213 |
);
|
| 214 |
}
|
|
|
|
| 1 |
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 2 |
import { Button } from "@labas/ui/components/button";
|
| 3 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
+
import {
|
| 5 |
+
Dialog,
|
| 6 |
+
DialogContent,
|
| 7 |
+
DialogHeader,
|
| 8 |
+
DialogTitle,
|
| 9 |
+
} from "@labas/ui/components/dialog";
|
| 10 |
import { authClient } from "@/lib/auth-client";
|
| 11 |
import { trpc } from "@/utils/trpc";
|
| 12 |
import { formatLabel } from "@/lib/format";
|
| 13 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 14 |
+
import type { Question } from "@/lib/types";
|
| 15 |
|
| 16 |
export function QuestionDetailModal({
|
| 17 |
question,
|
|
|
|
| 20 |
onToggleSelect,
|
| 21 |
isSelectable,
|
| 22 |
}: {
|
| 23 |
+
question: Question;
|
| 24 |
onClose: () => void;
|
| 25 |
isSelected: boolean;
|
| 26 |
onToggleSelect: () => void;
|
|
|
|
| 39 |
});
|
| 40 |
|
| 41 |
return (
|
| 42 |
+
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
| 43 |
+
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
| 44 |
+
<DialogHeader>
|
| 45 |
+
<div className="flex items-start justify-between">
|
| 46 |
+
<div className="flex gap-2 flex-wrap">
|
| 47 |
+
<span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
|
| 48 |
+
{question.examTypeName}
|
| 49 |
+
</span>
|
| 50 |
+
<span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
|
| 51 |
+
{question.sectionTypeName}
|
| 52 |
+
</span>
|
| 53 |
+
<span className="px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] text-sm font-semibold">
|
| 54 |
+
{formatLabel(question.format ?? "")}
|
| 55 |
+
</span>
|
| 56 |
+
<span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
|
| 57 |
+
Level {question.difficulty}
|
| 58 |
+
</span>
|
| 59 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
+
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
{/* Passage */}
|
| 64 |
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
|
|
|
|
| 109 |
<span className="font-semibold text-[var(--clay-black)]">
|
| 110 |
Jenis soal:{" "}
|
| 111 |
</span>
|
| 112 |
+
{formatLabel(question.format ?? "")}
|
| 113 |
</div>
|
| 114 |
)}
|
| 115 |
</CardContent>
|
|
|
|
| 136 |
<button
|
| 137 |
key={star}
|
| 138 |
onClick={() => rateMutation.mutate({ questionId: question.id, score: star })}
|
| 139 |
+
aria-label={`Nilai ${star} bintang`}
|
| 140 |
className="transition-transform hover:scale-110"
|
| 141 |
>
|
| 142 |
<MaterialIcon
|
|
|
|
| 206 |
Tutup
|
| 207 |
</Button>
|
| 208 |
</div>
|
| 209 |
+
</DialogContent>
|
| 210 |
+
</Dialog>
|
| 211 |
);
|
| 212 |
}
|
apps/web/src/components/bank/SectionBrowser.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 2 |
import { Button } from "@labas/ui/components/button";
|
| 3 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
|
|
|
| 4 |
|
| 5 |
interface SectionBrowserProps {
|
| 6 |
isLoading: boolean;
|
| 7 |
-
groupedSections: Record<string,
|
| 8 |
isSectionInBundle: (id: string) => boolean;
|
| 9 |
-
onToggleSection: (s:
|
| 10 |
}
|
| 11 |
|
| 12 |
export function SectionBrowser({
|
|
@@ -39,11 +40,11 @@ export function SectionBrowser({
|
|
| 39 |
{Object.entries(groupedSections).map(([groupKey, groupSections]) => (
|
| 40 |
<Card key={groupKey} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 41 |
<CardContent className="p-5">
|
| 42 |
-
<
|
| 43 |
{groupKey}
|
| 44 |
-
</
|
| 45 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 46 |
-
{groupSections.map((s
|
| 47 |
const inBundle = isSectionInBundle(s.id);
|
| 48 |
return (
|
| 49 |
<div
|
|
|
|
| 1 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 2 |
import { Button } from "@labas/ui/components/button";
|
| 3 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 4 |
+
import type { PackageSection } from "@/lib/types";
|
| 5 |
|
| 6 |
interface SectionBrowserProps {
|
| 7 |
isLoading: boolean;
|
| 8 |
+
groupedSections: Record<string, PackageSection[]>;
|
| 9 |
isSectionInBundle: (id: string) => boolean;
|
| 10 |
+
onToggleSection: (s: PackageSection) => void;
|
| 11 |
}
|
| 12 |
|
| 13 |
export function SectionBrowser({
|
|
|
|
| 40 |
{Object.entries(groupedSections).map(([groupKey, groupSections]) => (
|
| 41 |
<Card key={groupKey} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 42 |
<CardContent className="p-5">
|
| 43 |
+
<h2 className="font-headline font-bold text-[var(--clay-black)] mb-4 text-sm uppercase tracking-wider">
|
| 44 |
{groupKey}
|
| 45 |
+
</h2>
|
| 46 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
| 47 |
+
{groupSections.map((s) => {
|
| 48 |
const inBundle = isSectionInBundle(s.id);
|
| 49 |
return (
|
| 50 |
<div
|
apps/web/src/components/bank/SoalBrowser.tsx
CHANGED
|
@@ -4,10 +4,11 @@ import { Button } from "@labas/ui/components/button";
|
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
import { QuestionCard } from "./QuestionCard";
|
| 6 |
import { CalloutCard } from "./CalloutCard";
|
|
|
|
| 7 |
|
| 8 |
interface SoalBrowserProps {
|
| 9 |
isLoading: boolean;
|
| 10 |
-
questions:
|
| 11 |
hasMore: boolean;
|
| 12 |
isFetchingNextPage: boolean;
|
| 13 |
hasFilters: boolean;
|
|
@@ -16,8 +17,8 @@ interface SoalBrowserProps {
|
|
| 16 |
tab: "mine" | "public";
|
| 17 |
filterKey: string;
|
| 18 |
isQuestionInBundle: (id: string) => boolean;
|
| 19 |
-
onToggleQuestion: (q:
|
| 20 |
-
onOpenDetail: (q:
|
| 21 |
onTogglePublic: (id: string) => void;
|
| 22 |
onDelete: (id: string) => void;
|
| 23 |
onLoadMore: () => void;
|
|
@@ -47,7 +48,7 @@ export function SoalBrowser({
|
|
| 47 |
onPublishAllPrivate,
|
| 48 |
}: SoalBrowserProps) {
|
| 49 |
const sentinelRef = useRef<HTMLDivElement>(null);
|
| 50 |
-
const isLocked = (q:
|
| 51 |
|
| 52 |
// ββ Bulk mode ββ
|
| 53 |
const [bulkMode, setBulkMode] = useState(false);
|
|
@@ -64,7 +65,7 @@ export function SoalBrowser({
|
|
| 64 |
: false;
|
| 65 |
|
| 66 |
const privateQuestions = questions.filter(
|
| 67 |
-
(q
|
| 68 |
);
|
| 69 |
|
| 70 |
const handleDismissCallout = () => {
|
|
@@ -75,7 +76,7 @@ export function SoalBrowser({
|
|
| 75 |
|
| 76 |
const handlePublishAllPrivate = () => {
|
| 77 |
if (onPublishAllPrivate && privateQuestions.length > 0) {
|
| 78 |
-
onPublishAllPrivate(privateQuestions.map((q
|
| 79 |
}
|
| 80 |
};
|
| 81 |
|
|
@@ -104,7 +105,7 @@ export function SoalBrowser({
|
|
| 104 |
const clearSelection = () => setSelectedIds(new Set());
|
| 105 |
|
| 106 |
const selectAll = () => {
|
| 107 |
-
setSelectedIds(new Set(questions.map((q
|
| 108 |
};
|
| 109 |
|
| 110 |
const handleBulkPublish = () => {
|
|
|
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
import { QuestionCard } from "./QuestionCard";
|
| 6 |
import { CalloutCard } from "./CalloutCard";
|
| 7 |
+
import type { Question } from "@/lib/types";
|
| 8 |
|
| 9 |
interface SoalBrowserProps {
|
| 10 |
isLoading: boolean;
|
| 11 |
+
questions: Question[];
|
| 12 |
hasMore: boolean;
|
| 13 |
isFetchingNextPage: boolean;
|
| 14 |
hasFilters: boolean;
|
|
|
|
| 17 |
tab: "mine" | "public";
|
| 18 |
filterKey: string;
|
| 19 |
isQuestionInBundle: (id: string) => boolean;
|
| 20 |
+
onToggleQuestion: (q: Question) => void;
|
| 21 |
+
onOpenDetail: (q: Question) => void;
|
| 22 |
onTogglePublic: (id: string) => void;
|
| 23 |
onDelete: (id: string) => void;
|
| 24 |
onLoadMore: () => void;
|
|
|
|
| 48 |
onPublishAllPrivate,
|
| 49 |
}: SoalBrowserProps) {
|
| 50 |
const sentinelRef = useRef<HTMLDivElement>(null);
|
| 51 |
+
const isLocked = (q: Question) => !!lockedExamType && q.examTypeId !== lockedExamType;
|
| 52 |
|
| 53 |
// ββ Bulk mode ββ
|
| 54 |
const [bulkMode, setBulkMode] = useState(false);
|
|
|
|
| 65 |
: false;
|
| 66 |
|
| 67 |
const privateQuestions = questions.filter(
|
| 68 |
+
(q) => !q.isPublic && q.creatorUserId === userId,
|
| 69 |
);
|
| 70 |
|
| 71 |
const handleDismissCallout = () => {
|
|
|
|
| 76 |
|
| 77 |
const handlePublishAllPrivate = () => {
|
| 78 |
if (onPublishAllPrivate && privateQuestions.length > 0) {
|
| 79 |
+
onPublishAllPrivate(privateQuestions.map((q) => q.id));
|
| 80 |
}
|
| 81 |
};
|
| 82 |
|
|
|
|
| 105 |
const clearSelection = () => setSelectedIds(new Set());
|
| 106 |
|
| 107 |
const selectAll = () => {
|
| 108 |
+
setSelectedIds(new Set(questions.map((q) => q.id)));
|
| 109 |
};
|
| 110 |
|
| 111 |
const handleBulkPublish = () => {
|
apps/web/src/components/generate/ResultSection.tsx
CHANGED
|
@@ -15,7 +15,7 @@ export function ResultSection({ result, generatedPackageId, onClear }: ResultSec
|
|
| 15 |
<div className="mt-16 space-y-6">
|
| 16 |
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
| 17 |
<div>
|
| 18 |
-
<
|
| 19 |
<p className="text-sm text-[var(--warm-charcoal)] mt-1">
|
| 20 |
Paket latihan berhasil dibuat! Soal juga tersimpan di Bank Soal (privat). Jawaban & penjelasan disembunyikan agar latihan tetap fair.
|
| 21 |
</p>
|
|
|
|
| 15 |
<div className="mt-16 space-y-6">
|
| 16 |
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
| 17 |
<div>
|
| 18 |
+
<h3 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h3>
|
| 19 |
<p className="text-sm text-[var(--warm-charcoal)] mt-1">
|
| 20 |
Paket latihan berhasil dibuat! Soal juga tersimpan di Bank Soal (privat). Jawaban & penjelasan disembunyikan agar latihan tetap fair.
|
| 21 |
</p>
|
apps/web/src/components/package/EditPackageModal.tsx
CHANGED
|
@@ -1,7 +1,12 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
import { Button } from "@labas/ui/components/button";
|
| 4 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export function EditPackageModal({
|
| 7 |
initialTitle,
|
|
@@ -27,24 +32,13 @@ export function EditPackageModal({
|
|
| 27 |
const [isPublic, setIsPublic] = useState(initialIsPublic);
|
| 28 |
|
| 29 |
return (
|
| 30 |
-
<
|
| 31 |
-
className="
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
}}
|
| 35 |
-
>
|
| 36 |
-
<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">
|
| 37 |
-
<div className="flex items-center justify-between mb-6">
|
| 38 |
-
<h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
| 39 |
Edit Paket
|
| 40 |
-
</
|
| 41 |
-
|
| 42 |
-
onClick={onClose}
|
| 43 |
-
className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
|
| 44 |
-
>
|
| 45 |
-
<MaterialIcon name="close" className="text-[var(--clay-black)]" />
|
| 46 |
-
</button>
|
| 47 |
-
</div>
|
| 48 |
|
| 49 |
<div className="space-y-4">
|
| 50 |
<div>
|
|
@@ -100,7 +94,7 @@ export function EditPackageModal({
|
|
| 100 |
{isPending ? "Menyimpan..." : "Simpan"}
|
| 101 |
</Button>
|
| 102 |
</div>
|
| 103 |
-
</
|
| 104 |
-
</
|
| 105 |
);
|
| 106 |
}
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
import { Button } from "@labas/ui/components/button";
|
| 4 |
+
import {
|
| 5 |
+
Dialog,
|
| 6 |
+
DialogContent,
|
| 7 |
+
DialogHeader,
|
| 8 |
+
DialogTitle,
|
| 9 |
+
} from "@labas/ui/components/dialog";
|
| 10 |
|
| 11 |
export function EditPackageModal({
|
| 12 |
initialTitle,
|
|
|
|
| 32 |
const [isPublic, setIsPublic] = useState(initialIsPublic);
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
| 36 |
+
<DialogContent className="max-w-lg">
|
| 37 |
+
<DialogHeader>
|
| 38 |
+
<DialogTitle className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
Edit Paket
|
| 40 |
+
</DialogTitle>
|
| 41 |
+
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
<div className="space-y-4">
|
| 44 |
<div>
|
|
|
|
| 94 |
{isPending ? "Menyimpan..." : "Simpan"}
|
| 95 |
</Button>
|
| 96 |
</div>
|
| 97 |
+
</DialogContent>
|
| 98 |
+
</Dialog>
|
| 99 |
);
|
| 100 |
}
|
apps/web/src/components/routes/BankPage.tsx
CHANGED
|
@@ -17,6 +17,8 @@ import { SoalBrowser } from "@/components/bank/SoalBrowser";
|
|
| 17 |
import { SectionBrowser } from "@/components/bank/SectionBrowser";
|
| 18 |
import { BundleSidebar } from "@/components/bank/BundleSidebar";
|
| 19 |
import { toast } from "sonner";
|
|
|
|
|
|
|
| 20 |
import type { Step } from "react-joyride";
|
| 21 |
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 22 |
import z from "zod";
|
|
@@ -62,20 +64,20 @@ export function BankComponent() {
|
|
| 62 |
const visibilityFilter = search.visibility ?? "all";
|
| 63 |
|
| 64 |
// ββ Infinite scroll state ββ
|
| 65 |
-
const [allQuestions, setAllQuestions] = useState<
|
| 66 |
const [offset, setOffset] = useState(0);
|
| 67 |
const limit = 12;
|
| 68 |
const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
|
| 69 |
|
| 70 |
// ββ Sidebar / Bundle State ββ
|
| 71 |
-
const [bundleQuestions, setBundleQuestions] = useState<
|
| 72 |
-
const [bundleSections, setBundleSections] = useState<
|
| 73 |
const [bundleTitle, setBundleTitle] = useState("");
|
| 74 |
const [bundleDescription, setBundleDescription] = useState("");
|
| 75 |
const [bundleIsPublic, setBundleIsPublic] = useState(false);
|
| 76 |
|
| 77 |
// ββ Modals ββ
|
| 78 |
-
const [selectedQuestion, setSelectedQuestion] = useState<
|
| 79 |
const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
|
| 80 |
|
| 81 |
// ββ Filter UI State ββ
|
|
@@ -118,8 +120,8 @@ export function BankComponent() {
|
|
| 118 |
setAllQuestions(data.questions ?? []);
|
| 119 |
} else {
|
| 120 |
setAllQuestions((prev) => {
|
| 121 |
-
const existingIds = new Set(prev.map((q
|
| 122 |
-
const newQs = (data.questions ?? []).filter((q:
|
| 123 |
return [...prev, ...newQs];
|
| 124 |
});
|
| 125 |
}
|
|
@@ -158,23 +160,24 @@ export function BankComponent() {
|
|
| 158 |
onSuccess: () => questionQuery.refetch(),
|
| 159 |
});
|
| 160 |
|
| 161 |
-
const bulkPublish = useMutation(
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
| 178 |
|
| 179 |
// ββ Navigation helpers ββ
|
| 180 |
const setMode = (newMode: Mode) => {
|
|
@@ -258,7 +261,7 @@ export function BankComponent() {
|
|
| 258 |
const isSectionInBundle = (sid: string) =>
|
| 259 |
bundleSections.some((s) => s.id === sid);
|
| 260 |
|
| 261 |
-
const toggleQuestion = (q:
|
| 262 |
if (lockedExamType && q.examTypeId !== lockedExamType) {
|
| 263 |
toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
|
| 264 |
return;
|
|
@@ -270,7 +273,7 @@ export function BankComponent() {
|
|
| 270 |
});
|
| 271 |
};
|
| 272 |
|
| 273 |
-
const toggleSection = (s:
|
| 274 |
setBundleSections((prev) => {
|
| 275 |
const exists = prev.find((x) => x.id === s.id);
|
| 276 |
if (exists) return prev.filter((x) => x.id !== s.id);
|
|
@@ -314,8 +317,8 @@ export function BankComponent() {
|
|
| 314 |
setBundleQuestions([]);
|
| 315 |
setBundleTitle("");
|
| 316 |
setBundleDescription("");
|
| 317 |
-
} catch (err:
|
| 318 |
-
toast.error("Gagal membuat paket", { description: err
|
| 319 |
}
|
| 320 |
};
|
| 321 |
|
|
@@ -327,7 +330,7 @@ export function BankComponent() {
|
|
| 327 |
description: bundleDescription,
|
| 328 |
isPublic: bundleIsPublic,
|
| 329 |
sections: bundleSections.map((s, i) => ({
|
| 330 |
-
sourcePackageId: s.packageId,
|
| 331 |
sourceSectionId: s.id,
|
| 332 |
orderIndex: i,
|
| 333 |
})),
|
|
@@ -336,8 +339,8 @@ export function BankComponent() {
|
|
| 336 |
setBundleTitle("");
|
| 337 |
setBundleDescription("");
|
| 338 |
toast.success("Combo paket berhasil dibuat!");
|
| 339 |
-
} catch (err:
|
| 340 |
-
toast.error("Gagal membuat combo", { description: err
|
| 341 |
}
|
| 342 |
};
|
| 343 |
|
|
@@ -388,12 +391,12 @@ export function BankComponent() {
|
|
| 388 |
const questions = allQuestions;
|
| 389 |
|
| 390 |
const sections = sectionQuery.data?.sections ?? [];
|
| 391 |
-
const groupedSections = sections.reduce
|
| 392 |
const key = `${s.examTypeName ?? "Unknown"} β ${s.packageTitle ?? "Untitled"}`;
|
| 393 |
if (!groups[key]) groups[key] = [];
|
| 394 |
-
groups[key].push(s);
|
| 395 |
return groups;
|
| 396 |
-
}, {}
|
| 397 |
|
| 398 |
const isCreating =
|
| 399 |
createPackage.isPending ||
|
|
|
|
| 17 |
import { SectionBrowser } from "@/components/bank/SectionBrowser";
|
| 18 |
import { BundleSidebar } from "@/components/bank/BundleSidebar";
|
| 19 |
import { toast } from "sonner";
|
| 20 |
+
import { getErrorMessage } from "@/lib/error-utils";
|
| 21 |
+
import type { Question, PackageSection } from "@/lib/types";
|
| 22 |
import type { Step } from "react-joyride";
|
| 23 |
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 24 |
import z from "zod";
|
|
|
|
| 64 |
const visibilityFilter = search.visibility ?? "all";
|
| 65 |
|
| 66 |
// ββ Infinite scroll state ββ
|
| 67 |
+
const [allQuestions, setAllQuestions] = useState<Question[]>([]);
|
| 68 |
const [offset, setOffset] = useState(0);
|
| 69 |
const limit = 12;
|
| 70 |
const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
|
| 71 |
|
| 72 |
// ββ Sidebar / Bundle State ββ
|
| 73 |
+
const [bundleQuestions, setBundleQuestions] = useState<Question[]>([]);
|
| 74 |
+
const [bundleSections, setBundleSections] = useState<PackageSection[]>([]);
|
| 75 |
const [bundleTitle, setBundleTitle] = useState("");
|
| 76 |
const [bundleDescription, setBundleDescription] = useState("");
|
| 77 |
const [bundleIsPublic, setBundleIsPublic] = useState(false);
|
| 78 |
|
| 79 |
// ββ Modals ββ
|
| 80 |
+
const [selectedQuestion, setSelectedQuestion] = useState<Question | null>(null);
|
| 81 |
const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
|
| 82 |
|
| 83 |
// ββ Filter UI State ββ
|
|
|
|
| 120 |
setAllQuestions(data.questions ?? []);
|
| 121 |
} else {
|
| 122 |
setAllQuestions((prev) => {
|
| 123 |
+
const existingIds = new Set(prev.map((q) => q.id));
|
| 124 |
+
const newQs = (data.questions ?? []).filter((q: Question) => !existingIds.has(q.id));
|
| 125 |
return [...prev, ...newQs];
|
| 126 |
});
|
| 127 |
}
|
|
|
|
| 160 |
onSuccess: () => questionQuery.refetch(),
|
| 161 |
});
|
| 162 |
|
| 163 |
+
const bulkPublish = useMutation(
|
| 164 |
+
trpc.question.bulkPublish.mutationOptions({
|
| 165 |
+
onSuccess: (data) => {
|
| 166 |
+
questionQuery.refetch();
|
| 167 |
+
if (data.skipped > 0) {
|
| 168 |
+
toast.success(
|
| 169 |
+
`${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
|
| 170 |
+
{ description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
|
| 171 |
+
);
|
| 172 |
+
} else {
|
| 173 |
+
toast.success(`${data.updated} soal berhasil dipublikasikan`);
|
| 174 |
+
}
|
| 175 |
+
},
|
| 176 |
+
onError: (err) => {
|
| 177 |
+
toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: getErrorMessage(err) });
|
| 178 |
+
},
|
| 179 |
+
}),
|
| 180 |
+
);
|
| 181 |
|
| 182 |
// ββ Navigation helpers ββ
|
| 183 |
const setMode = (newMode: Mode) => {
|
|
|
|
| 261 |
const isSectionInBundle = (sid: string) =>
|
| 262 |
bundleSections.some((s) => s.id === sid);
|
| 263 |
|
| 264 |
+
const toggleQuestion = (q: Question) => {
|
| 265 |
if (lockedExamType && q.examTypeId !== lockedExamType) {
|
| 266 |
toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
|
| 267 |
return;
|
|
|
|
| 273 |
});
|
| 274 |
};
|
| 275 |
|
| 276 |
+
const toggleSection = (s: PackageSection) => {
|
| 277 |
setBundleSections((prev) => {
|
| 278 |
const exists = prev.find((x) => x.id === s.id);
|
| 279 |
if (exists) return prev.filter((x) => x.id !== s.id);
|
|
|
|
| 317 |
setBundleQuestions([]);
|
| 318 |
setBundleTitle("");
|
| 319 |
setBundleDescription("");
|
| 320 |
+
} catch (err: unknown) {
|
| 321 |
+
toast.error("Gagal membuat paket", { description: getErrorMessage(err) });
|
| 322 |
}
|
| 323 |
};
|
| 324 |
|
|
|
|
| 330 |
description: bundleDescription,
|
| 331 |
isPublic: bundleIsPublic,
|
| 332 |
sections: bundleSections.map((s, i) => ({
|
| 333 |
+
sourcePackageId: s.packageId ?? "",
|
| 334 |
sourceSectionId: s.id,
|
| 335 |
orderIndex: i,
|
| 336 |
})),
|
|
|
|
| 339 |
setBundleTitle("");
|
| 340 |
setBundleDescription("");
|
| 341 |
toast.success("Combo paket berhasil dibuat!");
|
| 342 |
+
} catch (err: unknown) {
|
| 343 |
+
toast.error("Gagal membuat combo", { description: getErrorMessage(err) });
|
| 344 |
}
|
| 345 |
};
|
| 346 |
|
|
|
|
| 391 |
const questions = allQuestions;
|
| 392 |
|
| 393 |
const sections = sectionQuery.data?.sections ?? [];
|
| 394 |
+
const groupedSections = sections.reduce<Record<string, PackageSection[]>>((groups, s) => {
|
| 395 |
const key = `${s.examTypeName ?? "Unknown"} β ${s.packageTitle ?? "Untitled"}`;
|
| 396 |
if (!groups[key]) groups[key] = [];
|
| 397 |
+
groups[key].push(s as PackageSection);
|
| 398 |
return groups;
|
| 399 |
+
}, {});
|
| 400 |
|
| 401 |
const isCreating =
|
| 402 |
createPackage.isPending ||
|
apps/web/src/components/routes/GeneratePage.tsx
CHANGED
|
@@ -181,17 +181,18 @@ export function RouteComponent() {
|
|
| 181 |
maxTokens: selectedConfig!.maxTokens ?? 16384,
|
| 182 |
};
|
| 183 |
|
|
|
|
| 184 |
generate.mutate({
|
| 185 |
-
examType
|
| 186 |
-
section: selectedSections[0]
|
| 187 |
-
selectedSections
|
| 188 |
-
formats: selectedFormats
|
| 189 |
difficulty: difficulty + 1,
|
| 190 |
topics: selectedTopics,
|
| 191 |
questionCount,
|
| 192 |
mode,
|
| 193 |
apiKeyConfig,
|
| 194 |
-
} as
|
| 195 |
};
|
| 196 |
|
| 197 |
const sectionSplits = (() => {
|
|
@@ -656,13 +657,37 @@ export function RouteComponent() {
|
|
| 656 |
</div>
|
| 657 |
|
| 658 |
{/* Tab Bar */}
|
| 659 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
{completedResults.map((res, idx) => {
|
| 661 |
const questions = res.result?.questions ?? [];
|
| 662 |
const isActive = idx === activeTabIdx;
|
| 663 |
return (
|
| 664 |
<button
|
| 665 |
key={res.jobId}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
onClick={() => setActiveTabIdx(idx)}
|
| 667 |
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
|
| 668 |
isActive
|
|
@@ -680,15 +705,18 @@ export function RouteComponent() {
|
|
| 680 |
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 681 |
{questions.length} soal
|
| 682 |
</span>
|
| 683 |
-
<
|
| 684 |
onClick={(e) => {
|
| 685 |
e.stopPropagation();
|
| 686 |
removeJob(res.jobId);
|
| 687 |
}}
|
| 688 |
-
|
|
|
|
|
|
|
|
|
|
| 689 |
>
|
| 690 |
<MaterialIcon name="close" className="text-xs" />
|
| 691 |
-
</
|
| 692 |
</button>
|
| 693 |
);
|
| 694 |
})}
|
|
@@ -696,11 +724,17 @@ export function RouteComponent() {
|
|
| 696 |
|
| 697 |
{/* Active Tab Content */}
|
| 698 |
{activeResult && (
|
| 699 |
-
<
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
)}
|
| 705 |
</div>
|
| 706 |
)}
|
|
|
|
| 181 |
maxTokens: selectedConfig!.maxTokens ?? 16384,
|
| 182 |
};
|
| 183 |
|
| 184 |
+
type GenerateMutateInput = Parameters<typeof generate.mutate>[0];
|
| 185 |
generate.mutate({
|
| 186 |
+
examType,
|
| 187 |
+
section: selectedSections[0],
|
| 188 |
+
selectedSections,
|
| 189 |
+
formats: selectedFormats,
|
| 190 |
difficulty: difficulty + 1,
|
| 191 |
topics: selectedTopics,
|
| 192 |
questionCount,
|
| 193 |
mode,
|
| 194 |
apiKeyConfig,
|
| 195 |
+
} as GenerateMutateInput);
|
| 196 |
};
|
| 197 |
|
| 198 |
const sectionSplits = (() => {
|
|
|
|
| 657 |
</div>
|
| 658 |
|
| 659 |
{/* Tab Bar */}
|
| 660 |
+
<div
|
| 661 |
+
className="flex gap-1 mb-6 border-b border-[var(--oat-border)] overflow-x-auto"
|
| 662 |
+
role="tablist"
|
| 663 |
+
aria-label="Hasil generate"
|
| 664 |
+
onKeyDown={(e) => {
|
| 665 |
+
if (completedResults.length === 0) return;
|
| 666 |
+
if (e.key === "ArrowRight") {
|
| 667 |
+
e.preventDefault();
|
| 668 |
+
setActiveTabIdx((i) => Math.min(completedResults.length - 1, i + 1));
|
| 669 |
+
} else if (e.key === "ArrowLeft") {
|
| 670 |
+
e.preventDefault();
|
| 671 |
+
setActiveTabIdx((i) => Math.max(0, i - 1));
|
| 672 |
+
} else if (e.key === "Home") {
|
| 673 |
+
e.preventDefault();
|
| 674 |
+
setActiveTabIdx(0);
|
| 675 |
+
} else if (e.key === "End") {
|
| 676 |
+
e.preventDefault();
|
| 677 |
+
setActiveTabIdx(completedResults.length - 1);
|
| 678 |
+
}
|
| 679 |
+
}}
|
| 680 |
+
>
|
| 681 |
{completedResults.map((res, idx) => {
|
| 682 |
const questions = res.result?.questions ?? [];
|
| 683 |
const isActive = idx === activeTabIdx;
|
| 684 |
return (
|
| 685 |
<button
|
| 686 |
key={res.jobId}
|
| 687 |
+
role="tab"
|
| 688 |
+
aria-selected={isActive}
|
| 689 |
+
aria-controls={`generate-panel-${idx}`}
|
| 690 |
+
tabIndex={isActive ? 0 : -1}
|
| 691 |
onClick={() => setActiveTabIdx(idx)}
|
| 692 |
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
|
| 693 |
isActive
|
|
|
|
| 705 |
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 706 |
{questions.length} soal
|
| 707 |
</span>
|
| 708 |
+
<span
|
| 709 |
onClick={(e) => {
|
| 710 |
e.stopPropagation();
|
| 711 |
removeJob(res.jobId);
|
| 712 |
}}
|
| 713 |
+
role="button"
|
| 714 |
+
aria-label={`Tutup tab ${res.mode}`}
|
| 715 |
+
tabIndex={-1}
|
| 716 |
+
className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/10 text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors cursor-pointer"
|
| 717 |
>
|
| 718 |
<MaterialIcon name="close" className="text-xs" />
|
| 719 |
+
</span>
|
| 720 |
</button>
|
| 721 |
);
|
| 722 |
})}
|
|
|
|
| 724 |
|
| 725 |
{/* Active Tab Content */}
|
| 726 |
{activeResult && (
|
| 727 |
+
<div
|
| 728 |
+
role="tabpanel"
|
| 729 |
+
id={`generate-panel-${activeTabIdx}`}
|
| 730 |
+
aria-labelledby={`generate-tab-${activeTabIdx}`}
|
| 731 |
+
>
|
| 732 |
+
<ResultSection
|
| 733 |
+
result={activeResult.result}
|
| 734 |
+
generatedPackageId={activeResult.generatedPackageId}
|
| 735 |
+
onClear={resetAll}
|
| 736 |
+
/>
|
| 737 |
+
</div>
|
| 738 |
)}
|
| 739 |
</div>
|
| 740 |
)}
|
apps/web/src/components/routes/PackagesPage.tsx
CHANGED
|
@@ -7,6 +7,11 @@ import { trpc } from "@/utils/trpc";
|
|
| 7 |
import { Input } from "@labas/ui/components/input";
|
| 8 |
import { Button } from "@labas/ui/components/button";
|
| 9 |
import { Card, CardContent } from "@labas/ui/components/card";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
import {
|
| 11 |
Select,
|
| 12 |
SelectContent,
|
|
@@ -209,28 +214,22 @@ export function PackagesComponent() {
|
|
| 209 |
<GettingStartedCard />
|
| 210 |
|
| 211 |
{/* Tabs */}
|
| 212 |
-
<
|
| 213 |
-
<
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 229 |
-
}`}
|
| 230 |
-
>
|
| 231 |
-
Paket Saya
|
| 232 |
-
</button>
|
| 233 |
-
</div>
|
| 234 |
|
| 235 |
{/* Filters */}
|
| 236 |
<div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
|
|
@@ -240,6 +239,7 @@ export function PackagesComponent() {
|
|
| 240 |
placeholder="Cari paket..."
|
| 241 |
value={searchText}
|
| 242 |
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
| 243 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 244 |
/>
|
| 245 |
</div>
|
|
|
|
| 7 |
import { Input } from "@labas/ui/components/input";
|
| 8 |
import { Button } from "@labas/ui/components/button";
|
| 9 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 10 |
+
import {
|
| 11 |
+
Tabs,
|
| 12 |
+
TabsList,
|
| 13 |
+
TabsTrigger,
|
| 14 |
+
} from "@labas/ui/components/tabs";
|
| 15 |
import {
|
| 16 |
Select,
|
| 17 |
SelectContent,
|
|
|
|
| 214 |
<GettingStartedCard />
|
| 215 |
|
| 216 |
{/* Tabs */}
|
| 217 |
+
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mb-6">
|
| 218 |
+
<TabsList variant="line" className="h-11 bg-transparent">
|
| 219 |
+
<TabsTrigger
|
| 220 |
+
value="all"
|
| 221 |
+
className="px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold data-[active=true]:bg-[var(--clay-black)] data-[active=true]:text-[var(--pure-white)] data-[active=true]:clay-shadow bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)] transition-all"
|
| 222 |
+
>
|
| 223 |
+
Semua Paket
|
| 224 |
+
</TabsTrigger>
|
| 225 |
+
<TabsTrigger
|
| 226 |
+
value="mine"
|
| 227 |
+
className="px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold data-[active=true]:bg-[var(--clay-black)] data-[active=true]:text-[var(--pure-white)] data-[active=true]:clay-shadow bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)] transition-all"
|
| 228 |
+
>
|
| 229 |
+
Paket Saya
|
| 230 |
+
</TabsTrigger>
|
| 231 |
+
</TabsList>
|
| 232 |
+
</Tabs>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
{/* Filters */}
|
| 235 |
<div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
|
|
|
|
| 239 |
placeholder="Cari paket..."
|
| 240 |
value={searchText}
|
| 241 |
onChange={(e) => setSearch(e.target.value)}
|
| 242 |
+
aria-label="Cari paket"
|
| 243 |
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 244 |
/>
|
| 245 |
</div>
|
apps/web/src/components/routes/SettingsPage.tsx
CHANGED
|
@@ -90,9 +90,9 @@ function TokenUsageSection() {
|
|
| 90 |
</div>
|
| 91 |
|
| 92 |
<div>
|
| 93 |
-
<
|
| 94 |
Riwayat Generate Hari Ini
|
| 95 |
-
</
|
| 96 |
{isLoading ? (
|
| 97 |
<div className="space-y-2">
|
| 98 |
{[1, 2, 3].map((i) => (
|
|
|
|
| 90 |
</div>
|
| 91 |
|
| 92 |
<div>
|
| 93 |
+
<h2 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
|
| 94 |
Riwayat Generate Hari Ini
|
| 95 |
+
</h2>
|
| 96 |
{isLoading ? (
|
| 97 |
<div className="space-y-2">
|
| 98 |
{[1, 2, 3].map((i) => (
|
apps/web/src/components/settings/AccountSettings.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { Input } from "@labas/ui/components/input";
|
|
| 4 |
import { Label } from "@labas/ui/components/label";
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { toast } from "sonner";
|
|
|
|
| 7 |
import { authClient } from "@/lib/auth-client";
|
| 8 |
|
| 9 |
export function AccountSettings() {
|
|
@@ -39,8 +40,8 @@ export function AccountSettings() {
|
|
| 39 |
setCurrentPassword("");
|
| 40 |
setNewPassword("");
|
| 41 |
setConfirmPassword("");
|
| 42 |
-
} catch (err:
|
| 43 |
-
toast.error(err
|
| 44 |
} finally {
|
| 45 |
setIsChangingPassword(false);
|
| 46 |
}
|
|
@@ -54,8 +55,8 @@ export function AccountSettings() {
|
|
| 54 |
await authClient.changeEmail({ newEmail });
|
| 55 |
toast.success("Email berhasil diubah. Cek email baru Anda untuk verifikasi.");
|
| 56 |
setNewEmail("");
|
| 57 |
-
} catch (err:
|
| 58 |
-
toast.error(err
|
| 59 |
} finally {
|
| 60 |
setIsChangingEmail(false);
|
| 61 |
}
|
|
|
|
| 4 |
import { Label } from "@labas/ui/components/label";
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
+
import { getErrorMessage } from "@/lib/error-utils";
|
| 8 |
import { authClient } from "@/lib/auth-client";
|
| 9 |
|
| 10 |
export function AccountSettings() {
|
|
|
|
| 40 |
setCurrentPassword("");
|
| 41 |
setNewPassword("");
|
| 42 |
setConfirmPassword("");
|
| 43 |
+
} catch (err: unknown) {
|
| 44 |
+
toast.error(getErrorMessage(err) || "Gagal mengubah password");
|
| 45 |
} finally {
|
| 46 |
setIsChangingPassword(false);
|
| 47 |
}
|
|
|
|
| 55 |
await authClient.changeEmail({ newEmail });
|
| 56 |
toast.success("Email berhasil diubah. Cek email baru Anda untuk verifikasi.");
|
| 57 |
setNewEmail("");
|
| 58 |
+
} catch (err: unknown) {
|
| 59 |
+
toast.error(getErrorMessage(err) || "Gagal mengubah email");
|
| 60 |
} finally {
|
| 61 |
setIsChangingEmail(false);
|
| 62 |
}
|
apps/web/src/components/test/AccentKeyboard.tsx
CHANGED
|
@@ -7,13 +7,14 @@ const ACCENT_CHARS: Record<string, string[]> = {
|
|
| 7 |
};
|
| 8 |
|
| 9 |
interface AccentKeyboardProps {
|
| 10 |
-
examType: string;
|
| 11 |
onInsert: (char: string) => void;
|
| 12 |
disabled?: boolean;
|
| 13 |
}
|
| 14 |
|
| 15 |
export function AccentKeyboard({ examType, onInsert, disabled }: AccentKeyboardProps) {
|
| 16 |
-
|
|
|
|
| 17 |
if (!chars || chars.length === 0) return null;
|
| 18 |
|
| 19 |
return (
|
|
@@ -21,7 +22,7 @@ export function AccentKeyboard({ examType, onInsert, disabled }: AccentKeyboardP
|
|
| 21 |
<span className="w-full text-[10px] font-medium text-[var(--warm-charcoal)] uppercase tracking-wider mb-0.5">
|
| 22 |
Special Characters
|
| 23 |
</span>
|
| 24 |
-
{chars.map((char) => (
|
| 25 |
<button
|
| 26 |
key={char}
|
| 27 |
type="button"
|
|
|
|
| 7 |
};
|
| 8 |
|
| 9 |
interface AccentKeyboardProps {
|
| 10 |
+
examType?: string;
|
| 11 |
onInsert: (char: string) => void;
|
| 12 |
disabled?: boolean;
|
| 13 |
}
|
| 14 |
|
| 15 |
export function AccentKeyboard({ examType, onInsert, disabled }: AccentKeyboardProps) {
|
| 16 |
+
if (!examType) return null;
|
| 17 |
+
const chars: string[] | undefined = ACCENT_CHARS[examType];
|
| 18 |
if (!chars || chars.length === 0) return null;
|
| 19 |
|
| 20 |
return (
|
|
|
|
| 22 |
<span className="w-full text-[10px] font-medium text-[var(--warm-charcoal)] uppercase tracking-wider mb-0.5">
|
| 23 |
Special Characters
|
| 24 |
</span>
|
| 25 |
+
{chars.map((char: string) => (
|
| 26 |
<button
|
| 27 |
key={char}
|
| 28 |
type="button"
|
apps/web/src/components/test/AttemptTestView.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { trpc } from "@/utils/trpc";
|
|
| 7 |
import { QuestionInput } from "./QuestionInput";
|
| 8 |
import { AccentKeyboard } from "./AccentKeyboard";
|
| 9 |
import { parseFurigana } from "@/lib/furigana";
|
|
|
|
| 10 |
import { AttemptHeader } from "./attempt/AttemptHeader";
|
| 11 |
import { FloatingNav } from "./attempt/FloatingNav";
|
| 12 |
import { FinishDialog } from "./attempt/FinishDialog";
|
|
@@ -14,7 +15,7 @@ import { AbandonDialog } from "./attempt/AbandonDialog";
|
|
| 14 |
|
| 15 |
interface AttemptTestViewProps {
|
| 16 |
attemptId: string;
|
| 17 |
-
pkg:
|
| 18 |
currentSectionIdx: number;
|
| 19 |
setCurrentSectionIdx: (idx: number) => void;
|
| 20 |
answers: Record<string, string>;
|
|
@@ -32,7 +33,7 @@ interface AttemptTestViewProps {
|
|
| 32 |
}
|
| 33 |
|
| 34 |
interface QuestionCardProps {
|
| 35 |
-
q:
|
| 36 |
globalIdx: number;
|
| 37 |
answerValue: string;
|
| 38 |
sectionResultId: string | undefined;
|
|
@@ -83,7 +84,7 @@ const QuestionCard = memo(function QuestionCard({
|
|
| 83 |
{globalIdx}
|
| 84 |
</span>
|
| 85 |
<h2 className="text-xl font-bold text-[var(--clay-black)] capitalize">
|
| 86 |
-
{q.format.replace(/_/g, " ")}
|
| 87 |
</h2>
|
| 88 |
</div>
|
| 89 |
<button
|
|
@@ -101,7 +102,7 @@ const QuestionCard = memo(function QuestionCard({
|
|
| 101 |
</div>
|
| 102 |
|
| 103 |
<p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed" dir={q._isRtl ? "rtl" : undefined}>
|
| 104 |
-
{q._useFurigana ? parseFurigana(q.questionText) : q.questionText}
|
| 105 |
</p>
|
| 106 |
|
| 107 |
<div className="pl-0">
|
|
@@ -165,7 +166,7 @@ export function AttemptTestView({
|
|
| 165 |
const currentSection = pkg.sections[currentSectionIdx];
|
| 166 |
const sectionData = attempt?.sections?.[currentSectionIdx];
|
| 167 |
const sectionResultId = sectionData?.sectionResultId;
|
| 168 |
-
const examType: string = pkg.
|
| 169 |
const isRtl = examType === "TOAFL";
|
| 170 |
const useFurigana = examType === "JLPT" || examType === "TOPIK";
|
| 171 |
|
|
@@ -176,7 +177,7 @@ export function AttemptTestView({
|
|
| 176 |
const key = `${currentSectionIdx}:${sectionResultId}`;
|
| 177 |
if (flushKeyRef.current === key) return;
|
| 178 |
flushKeyRef.current = key;
|
| 179 |
-
for (const q of currentSection.questions
|
| 180 |
const v = answers[q.id];
|
| 181 |
if (v) void onAnswerChange(q.id, sectionResultId, v);
|
| 182 |
}
|
|
@@ -193,9 +194,9 @@ export function AttemptTestView({
|
|
| 193 |
|
| 194 |
// Build global question index across all sections
|
| 195 |
const allQuestions: Array<{ id: string; sectionIdx: number; localIdx: number; passageText?: string }> = [];
|
| 196 |
-
pkg.sections.forEach((sec:
|
| 197 |
-
sec.questions.forEach((q:
|
| 198 |
-
allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText });
|
| 199 |
});
|
| 200 |
});
|
| 201 |
|
|
@@ -207,7 +208,7 @@ export function AttemptTestView({
|
|
| 207 |
}
|
| 208 |
}, [allQuestions]);
|
| 209 |
|
| 210 |
-
const addQuestionMeta = (q:
|
| 211 |
...q,
|
| 212 |
_examType: examType,
|
| 213 |
_isRtl: isRtl,
|
|
@@ -215,7 +216,7 @@ export function AttemptTestView({
|
|
| 215 |
});
|
| 216 |
|
| 217 |
const activeQuestion = currentSection?.questions?.find(
|
| 218 |
-
(q:
|
| 219 |
);
|
| 220 |
const activeQuestionWithMeta = activeQuestion ? addQuestionMeta(activeQuestion) : null;
|
| 221 |
const passageToShow =
|
|
@@ -279,8 +280,8 @@ export function AttemptTestView({
|
|
| 279 |
|
| 280 |
<div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
|
| 281 |
<AttemptHeader
|
| 282 |
-
pkgTitle={pkg.title}
|
| 283 |
-
currentSectionTitle={currentSection.title}
|
| 284 |
timeElapsed={timeElapsed}
|
| 285 |
answeredCount={answeredCount}
|
| 286 |
totalQuestions={totalQuestions}
|
|
|
|
| 7 |
import { QuestionInput } from "./QuestionInput";
|
| 8 |
import { AccentKeyboard } from "./AccentKeyboard";
|
| 9 |
import { parseFurigana } from "@/lib/furigana";
|
| 10 |
+
import type { Question, Package, PackageSection } from "@/lib/types";
|
| 11 |
import { AttemptHeader } from "./attempt/AttemptHeader";
|
| 12 |
import { FloatingNav } from "./attempt/FloatingNav";
|
| 13 |
import { FinishDialog } from "./attempt/FinishDialog";
|
|
|
|
| 15 |
|
| 16 |
interface AttemptTestViewProps {
|
| 17 |
attemptId: string;
|
| 18 |
+
pkg: Package & { sections: PackageSection[] };
|
| 19 |
currentSectionIdx: number;
|
| 20 |
setCurrentSectionIdx: (idx: number) => void;
|
| 21 |
answers: Record<string, string>;
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
interface QuestionCardProps {
|
| 36 |
+
q: Question & { _examType?: string; _isRtl?: boolean; _useFurigana?: boolean };
|
| 37 |
globalIdx: number;
|
| 38 |
answerValue: string;
|
| 39 |
sectionResultId: string | undefined;
|
|
|
|
| 84 |
{globalIdx}
|
| 85 |
</span>
|
| 86 |
<h2 className="text-xl font-bold text-[var(--clay-black)] capitalize">
|
| 87 |
+
{(q.format ?? "").replace(/_/g, " ")}
|
| 88 |
</h2>
|
| 89 |
</div>
|
| 90 |
<button
|
|
|
|
| 102 |
</div>
|
| 103 |
|
| 104 |
<p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed" dir={q._isRtl ? "rtl" : undefined}>
|
| 105 |
+
{q._useFurigana ? parseFurigana(q.questionText ?? "") : (q.questionText ?? "")}
|
| 106 |
</p>
|
| 107 |
|
| 108 |
<div className="pl-0">
|
|
|
|
| 166 |
const currentSection = pkg.sections[currentSectionIdx];
|
| 167 |
const sectionData = attempt?.sections?.[currentSectionIdx];
|
| 168 |
const sectionResultId = sectionData?.sectionResultId;
|
| 169 |
+
const examType: string = pkg.examTypeName ?? "";
|
| 170 |
const isRtl = examType === "TOAFL";
|
| 171 |
const useFurigana = examType === "JLPT" || examType === "TOPIK";
|
| 172 |
|
|
|
|
| 177 |
const key = `${currentSectionIdx}:${sectionResultId}`;
|
| 178 |
if (flushKeyRef.current === key) return;
|
| 179 |
flushKeyRef.current = key;
|
| 180 |
+
for (const q of currentSection.questions ?? []) {
|
| 181 |
const v = answers[q.id];
|
| 182 |
if (v) void onAnswerChange(q.id, sectionResultId, v);
|
| 183 |
}
|
|
|
|
| 194 |
|
| 195 |
// Build global question index across all sections
|
| 196 |
const allQuestions: Array<{ id: string; sectionIdx: number; localIdx: number; passageText?: string }> = [];
|
| 197 |
+
pkg.sections.forEach((sec: PackageSection, sIdx: number) => {
|
| 198 |
+
sec.questions?.forEach((q: Question, qIdx: number) => {
|
| 199 |
+
allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText ?? undefined });
|
| 200 |
});
|
| 201 |
});
|
| 202 |
|
|
|
|
| 208 |
}
|
| 209 |
}, [allQuestions]);
|
| 210 |
|
| 211 |
+
const addQuestionMeta = (q: Question) => ({
|
| 212 |
...q,
|
| 213 |
_examType: examType,
|
| 214 |
_isRtl: isRtl,
|
|
|
|
| 216 |
});
|
| 217 |
|
| 218 |
const activeQuestion = currentSection?.questions?.find(
|
| 219 |
+
(q: Question) => q.id === activeQuestionId,
|
| 220 |
);
|
| 221 |
const activeQuestionWithMeta = activeQuestion ? addQuestionMeta(activeQuestion) : null;
|
| 222 |
const passageToShow =
|
|
|
|
| 280 |
|
| 281 |
<div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
|
| 282 |
<AttemptHeader
|
| 283 |
+
pkgTitle={pkg.title ?? ""}
|
| 284 |
+
currentSectionTitle={currentSection.title ?? ""}
|
| 285 |
timeElapsed={timeElapsed}
|
| 286 |
answeredCount={answeredCount}
|
| 287 |
totalQuestions={totalQuestions}
|
apps/web/src/components/test/QuestionInput.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
|
| 4 |
import { MCQ_FORMATS } from "@/lib/question-formats";
|
|
|
|
| 5 |
|
| 6 |
const TRUE_FALSE_CHOICES = [
|
| 7 |
{ key: "TRUE", label: "True" },
|
|
@@ -82,6 +83,7 @@ function DebouncedTextInput({
|
|
| 82 |
|
| 83 |
return (
|
| 84 |
<Input
|
|
|
|
| 85 |
value={localValue}
|
| 86 |
onChange={handleChange}
|
| 87 |
onBlur={handleBlur}
|
|
@@ -98,7 +100,7 @@ export function QuestionInput({
|
|
| 98 |
onChange,
|
| 99 |
disabled,
|
| 100 |
}: {
|
| 101 |
-
question:
|
| 102 |
value: string;
|
| 103 |
onChange: (val: string) => void;
|
| 104 |
disabled?: boolean;
|
|
@@ -147,6 +149,7 @@ export function QuestionInput({
|
|
| 147 |
onChange={(e) => updateMapping(opt.key, e.target.value)}
|
| 148 |
disabled={disabled}
|
| 149 |
placeholder="Padanan..."
|
|
|
|
| 150 |
className="w-24 px-3 py-2 text-sm rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] focus:outline-none focus:border-[var(--matcha-600)]"
|
| 151 |
/>
|
| 152 |
</div>
|
|
@@ -156,7 +159,7 @@ export function QuestionInput({
|
|
| 156 |
);
|
| 157 |
}
|
| 158 |
|
| 159 |
-
if (MCQ_FORMATS.includes(format)) {
|
| 160 |
if (!options || options.length === 0) {
|
| 161 |
return (
|
| 162 |
<div className="text-sm text-[var(--warm-silver)] italic">
|
|
|
|
| 2 |
import { Input } from "@labas/ui/components/input";
|
| 3 |
|
| 4 |
import { MCQ_FORMATS } from "@/lib/question-formats";
|
| 5 |
+
import type { Question } from "@/lib/types";
|
| 6 |
|
| 7 |
const TRUE_FALSE_CHOICES = [
|
| 8 |
{ key: "TRUE", label: "True" },
|
|
|
|
| 83 |
|
| 84 |
return (
|
| 85 |
<Input
|
| 86 |
+
aria-label="Jawaban esai"
|
| 87 |
value={localValue}
|
| 88 |
onChange={handleChange}
|
| 89 |
onBlur={handleBlur}
|
|
|
|
| 100 |
onChange,
|
| 101 |
disabled,
|
| 102 |
}: {
|
| 103 |
+
question: Question;
|
| 104 |
value: string;
|
| 105 |
onChange: (val: string) => void;
|
| 106 |
disabled?: boolean;
|
|
|
|
| 149 |
onChange={(e) => updateMapping(opt.key, e.target.value)}
|
| 150 |
disabled={disabled}
|
| 151 |
placeholder="Padanan..."
|
| 152 |
+
aria-label="Padanan jawaban"
|
| 153 |
className="w-24 px-3 py-2 text-sm rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] focus:outline-none focus:border-[var(--matcha-600)]"
|
| 154 |
/>
|
| 155 |
</div>
|
|
|
|
| 159 |
);
|
| 160 |
}
|
| 161 |
|
| 162 |
+
if ((MCQ_FORMATS as readonly string[]).includes(format ?? "")) {
|
| 163 |
if (!options || options.length === 0) {
|
| 164 |
return (
|
| 165 |
<div className="text-sm text-[var(--warm-silver)] italic">
|
apps/web/src/components/test/attempt/AbandonDialog.tsx
CHANGED
|
@@ -1,4 +1,11 @@
|
|
| 1 |
import { Button } from "@labas/ui/components/button";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
|
| 4 |
interface AbandonDialogProps {
|
|
@@ -8,35 +15,23 @@ interface AbandonDialogProps {
|
|
| 8 |
}
|
| 9 |
|
| 10 |
export function AbandonDialog({ open, onClose, onConfirm }: AbandonDialogProps) {
|
| 11 |
-
if (!open) return null;
|
| 12 |
-
|
| 13 |
return (
|
| 14 |
-
<
|
| 15 |
-
className="
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
<div className="flex items-center gap-3 mb-4">
|
| 25 |
-
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 26 |
-
<MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
|
| 27 |
</div>
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
</
|
| 32 |
-
|
| 33 |
-
<div className="space-y-3 mb-6">
|
| 34 |
-
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 35 |
-
Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
|
| 36 |
-
</p>
|
| 37 |
-
</div>
|
| 38 |
-
|
| 39 |
-
<div className="flex gap-3">
|
| 40 |
<Button
|
| 41 |
variant="outline"
|
| 42 |
onClick={onClose}
|
|
@@ -51,7 +46,7 @@ export function AbandonDialog({ open, onClose, onConfirm }: AbandonDialogProps)
|
|
| 51 |
Keluar
|
| 52 |
</Button>
|
| 53 |
</div>
|
| 54 |
-
</
|
| 55 |
-
</
|
| 56 |
);
|
| 57 |
}
|
|
|
|
| 1 |
import { Button } from "@labas/ui/components/button";
|
| 2 |
+
import {
|
| 3 |
+
Dialog,
|
| 4 |
+
DialogContent,
|
| 5 |
+
DialogHeader,
|
| 6 |
+
DialogTitle,
|
| 7 |
+
DialogDescription,
|
| 8 |
+
} from "@labas/ui/components/dialog";
|
| 9 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
|
| 11 |
interface AbandonDialogProps {
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export function AbandonDialog({ open, onClose, onConfirm }: AbandonDialogProps) {
|
|
|
|
|
|
|
| 18 |
return (
|
| 19 |
+
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
| 20 |
+
<DialogContent className="max-w-md">
|
| 21 |
+
<DialogHeader>
|
| 22 |
+
<div className="flex items-center gap-3">
|
| 23 |
+
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 24 |
+
<MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
|
| 25 |
+
</div>
|
| 26 |
+
<DialogTitle className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 27 |
+
Keluar dari Latihan?
|
| 28 |
+
</DialogTitle>
|
|
|
|
|
|
|
|
|
|
| 29 |
</div>
|
| 30 |
+
</DialogHeader>
|
| 31 |
+
<DialogDescription className="text-sm text-[var(--warm-charcoal)]">
|
| 32 |
+
Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
|
| 33 |
+
</DialogDescription>
|
| 34 |
+
<div className="flex gap-3 mt-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
<Button
|
| 36 |
variant="outline"
|
| 37 |
onClick={onClose}
|
|
|
|
| 46 |
Keluar
|
| 47 |
</Button>
|
| 48 |
</div>
|
| 49 |
+
</DialogContent>
|
| 50 |
+
</Dialog>
|
| 51 |
);
|
| 52 |
}
|
apps/web/src/components/test/attempt/FinishDialog.tsx
CHANGED
|
@@ -1,4 +1,11 @@
|
|
| 1 |
import { Button } from "@labas/ui/components/button";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
|
| 4 |
interface FinishDialogProps {
|
|
@@ -10,44 +17,37 @@ interface FinishDialogProps {
|
|
| 10 |
}
|
| 11 |
|
| 12 |
export function FinishDialog({ open, onClose, onConfirm, answeredCount, totalQuestions }: FinishDialogProps) {
|
| 13 |
-
if (!open) return null;
|
| 14 |
-
|
| 15 |
return (
|
| 16 |
-
<
|
| 17 |
-
className="
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
<div className="flex items-center gap-3 mb-4">
|
| 27 |
-
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 28 |
-
<MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
|
| 29 |
</div>
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
<p className="text-sm text-[var(--warm-charcoal)]">
|
| 37 |
Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
|
| 38 |
</p>
|
| 39 |
{answeredCount < totalQuestions && (
|
| 40 |
-
<div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-
|
| 41 |
<MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
|
| 42 |
<span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
|
| 43 |
</div>
|
| 44 |
)}
|
| 45 |
-
<p className="text-
|
| 46 |
Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
|
| 47 |
</p>
|
| 48 |
-
</
|
| 49 |
-
|
| 50 |
-
<div className="flex gap-3">
|
| 51 |
<Button
|
| 52 |
variant="outline"
|
| 53 |
onClick={onClose}
|
|
@@ -62,7 +62,7 @@ export function FinishDialog({ open, onClose, onConfirm, answeredCount, totalQue
|
|
| 62 |
Selesaikan
|
| 63 |
</Button>
|
| 64 |
</div>
|
| 65 |
-
</
|
| 66 |
-
</
|
| 67 |
);
|
| 68 |
}
|
|
|
|
| 1 |
import { Button } from "@labas/ui/components/button";
|
| 2 |
+
import {
|
| 3 |
+
Dialog,
|
| 4 |
+
DialogContent,
|
| 5 |
+
DialogHeader,
|
| 6 |
+
DialogTitle,
|
| 7 |
+
DialogDescription,
|
| 8 |
+
} from "@labas/ui/components/dialog";
|
| 9 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
|
| 11 |
interface FinishDialogProps {
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
export function FinishDialog({ open, onClose, onConfirm, answeredCount, totalQuestions }: FinishDialogProps) {
|
|
|
|
|
|
|
| 20 |
return (
|
| 21 |
+
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
| 22 |
+
<DialogContent className="max-w-md">
|
| 23 |
+
<DialogHeader>
|
| 24 |
+
<div className="flex items-center gap-3">
|
| 25 |
+
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
|
| 26 |
+
<MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
|
| 27 |
+
</div>
|
| 28 |
+
<DialogTitle className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 29 |
+
Selesaikan Latihan?
|
| 30 |
+
</DialogTitle>
|
|
|
|
|
|
|
|
|
|
| 31 |
</div>
|
| 32 |
+
</DialogHeader>
|
| 33 |
+
<DialogDescription
|
| 34 |
+
className="space-y-3 *:text-sm"
|
| 35 |
+
render={<div />}
|
| 36 |
+
>
|
| 37 |
+
<p className="text-[var(--warm-charcoal)]">
|
|
|
|
| 38 |
Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
|
| 39 |
</p>
|
| 40 |
{answeredCount < totalQuestions && (
|
| 41 |
+
<div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-[var(--lemon-800)] flex items-start gap-2">
|
| 42 |
<MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
|
| 43 |
<span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
|
| 44 |
</div>
|
| 45 |
)}
|
| 46 |
+
<p className="text-[var(--warm-charcoal)]">
|
| 47 |
Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
|
| 48 |
</p>
|
| 49 |
+
</DialogDescription>
|
| 50 |
+
<div className="flex gap-3 mt-2">
|
|
|
|
| 51 |
<Button
|
| 52 |
variant="outline"
|
| 53 |
onClick={onClose}
|
|
|
|
| 62 |
Selesaikan
|
| 63 |
</Button>
|
| 64 |
</div>
|
| 65 |
+
</DialogContent>
|
| 66 |
+
</Dialog>
|
| 67 |
);
|
| 68 |
}
|
apps/web/src/components/test/attempt/FloatingNav.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import type { RefObject } from "react";
|
| 2 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
|
| 4 |
interface FloatingNavProps {
|
|
@@ -23,6 +23,26 @@ export function FloatingNav({
|
|
| 23 |
const isAnswered = (qId: string) => !!answers[qId];
|
| 24 |
const isMarked = (qId: string) => markedQuestions.has(qId);
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return (
|
| 27 |
<nav
|
| 28 |
className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]"
|
|
@@ -43,6 +63,7 @@ export function FloatingNav({
|
|
| 43 |
className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
|
| 44 |
role="tablist"
|
| 45 |
aria-label="Daftar soal"
|
|
|
|
| 46 |
>
|
| 47 |
{questions.map((q, gIdx) => {
|
| 48 |
const answered = isAnswered(q.id);
|
|
@@ -56,6 +77,8 @@ export function FloatingNav({
|
|
| 56 |
onClick={() => onGoToQuestion(q.id)}
|
| 57 |
role="tab"
|
| 58 |
aria-selected={isActive}
|
|
|
|
|
|
|
| 59 |
aria-label={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}${answered ? " (sudah dijawab)" : ""}`}
|
| 60 |
className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
|
| 61 |
answered
|
|
|
|
| 1 |
+
import type { RefObject, KeyboardEvent } from "react";
|
| 2 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
|
| 4 |
interface FloatingNavProps {
|
|
|
|
| 23 |
const isAnswered = (qId: string) => !!answers[qId];
|
| 24 |
const isMarked = (qId: string) => markedQuestions.has(qId);
|
| 25 |
|
| 26 |
+
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
| 27 |
+
if (questions.length === 0) return;
|
| 28 |
+
const activeIdx = questions.findIndex((q) => q.id === activeQuestionId);
|
| 29 |
+
if (e.key === "ArrowRight") {
|
| 30 |
+
e.preventDefault();
|
| 31 |
+
const next = Math.min(questions.length - 1, Math.max(0, activeIdx) + 1);
|
| 32 |
+
onGoToQuestion(questions[next].id);
|
| 33 |
+
} else if (e.key === "ArrowLeft") {
|
| 34 |
+
e.preventDefault();
|
| 35 |
+
const prev = Math.max(0, Math.max(0, activeIdx) - 1);
|
| 36 |
+
onGoToQuestion(questions[prev].id);
|
| 37 |
+
} else if (e.key === "Home") {
|
| 38 |
+
e.preventDefault();
|
| 39 |
+
onGoToQuestion(questions[0].id);
|
| 40 |
+
} else if (e.key === "End") {
|
| 41 |
+
e.preventDefault();
|
| 42 |
+
onGoToQuestion(questions[questions.length - 1].id);
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
return (
|
| 47 |
<nav
|
| 48 |
className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]"
|
|
|
|
| 63 |
className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
|
| 64 |
role="tablist"
|
| 65 |
aria-label="Daftar soal"
|
| 66 |
+
onKeyDown={handleKeyDown}
|
| 67 |
>
|
| 68 |
{questions.map((q, gIdx) => {
|
| 69 |
const answered = isAnswered(q.id);
|
|
|
|
| 77 |
onClick={() => onGoToQuestion(q.id)}
|
| 78 |
role="tab"
|
| 79 |
aria-selected={isActive}
|
| 80 |
+
tabIndex={isActive ? 0 : -1}
|
| 81 |
+
aria-controls={`question-panel-${q.id}`}
|
| 82 |
aria-label={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}${answered ? " (sudah dijawab)" : ""}`}
|
| 83 |
className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
|
| 84 |
answered
|
apps/web/src/hooks/use-generation-jobs.ts
CHANGED
|
@@ -1,277 +1,54 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { useQuery
|
| 3 |
import { trpc, trpcClient } from "@/utils/trpc";
|
| 4 |
-
import type { GenerationResult } from "@labas/ai";
|
| 5 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
const RESULTS_KEY = "labas_completed_results";
|
| 9 |
-
const CLEARED_JOBS_KEY = "labas_cleared_jobs";
|
| 10 |
-
const MAX_PARALLEL = 3;
|
| 11 |
-
|
| 12 |
-
export interface ActiveJob {
|
| 13 |
-
id: string;
|
| 14 |
-
status: string;
|
| 15 |
-
progress: number;
|
| 16 |
-
progressMessage: string;
|
| 17 |
-
mode: string;
|
| 18 |
-
logs: unknown[];
|
| 19 |
-
resultJson: unknown;
|
| 20 |
-
errorMessage?: string;
|
| 21 |
-
examTypeId?: string;
|
| 22 |
-
sectionTypeId?: string;
|
| 23 |
-
questionCount?: number;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
export interface CompletedResult {
|
| 27 |
-
jobId: string;
|
| 28 |
-
result: GenerationResult;
|
| 29 |
-
generatedPackageId: string | null;
|
| 30 |
-
examType?: string;
|
| 31 |
-
section?: string;
|
| 32 |
-
mode: string;
|
| 33 |
-
timestamp: number;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
const ACTIVE_STATUSES = new Set([
|
| 37 |
-
"pending",
|
| 38 |
-
"running",
|
| 39 |
-
"running_fast",
|
| 40 |
-
"partial_ready",
|
| 41 |
-
"running_quality",
|
| 42 |
-
]);
|
| 43 |
-
|
| 44 |
-
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
| 45 |
-
|
| 46 |
-
function isTerminal(status: string): boolean {
|
| 47 |
-
return TERMINAL_STATUSES.has(status);
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
function readStoredIds(): string[] {
|
| 51 |
-
try {
|
| 52 |
-
const raw = sessionStorage.getItem(STORAGE_KEY);
|
| 53 |
-
return raw ? (JSON.parse(raw) as string[]) : [];
|
| 54 |
-
} catch {
|
| 55 |
-
return [];
|
| 56 |
-
}
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
function writeStoredIds(ids: string[]) {
|
| 60 |
-
if (ids.length === 0) {
|
| 61 |
-
sessionStorage.removeItem(STORAGE_KEY);
|
| 62 |
-
} else {
|
| 63 |
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
| 64 |
-
}
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
function readStoredResults(): CompletedResult[] {
|
| 68 |
-
try {
|
| 69 |
-
const raw = sessionStorage.getItem(RESULTS_KEY);
|
| 70 |
-
return raw ? (JSON.parse(raw) as CompletedResult[]) : [];
|
| 71 |
-
} catch {
|
| 72 |
-
return [];
|
| 73 |
-
}
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
function writeStoredResults(results: CompletedResult[]) {
|
| 77 |
-
if (results.length === 0) {
|
| 78 |
-
sessionStorage.removeItem(RESULTS_KEY);
|
| 79 |
-
} else {
|
| 80 |
-
sessionStorage.setItem(RESULTS_KEY, JSON.stringify(results));
|
| 81 |
-
}
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
function readClearedJobIds(): string[] {
|
| 85 |
-
try {
|
| 86 |
-
const raw = sessionStorage.getItem(CLEARED_JOBS_KEY);
|
| 87 |
-
return raw ? (JSON.parse(raw) as string[]) : [];
|
| 88 |
-
} catch {
|
| 89 |
-
return [];
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
function writeClearedJobIds(ids: string[]) {
|
| 94 |
-
if (ids.length === 0) {
|
| 95 |
-
sessionStorage.removeItem(CLEARED_JOBS_KEY);
|
| 96 |
-
} else {
|
| 97 |
-
sessionStorage.setItem(CLEARED_JOBS_KEY, JSON.stringify(ids));
|
| 98 |
-
}
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
function broadcastJobs(ids: string[]) {
|
| 102 |
-
window.dispatchEvent(
|
| 103 |
-
new CustomEvent("labas:jobs-change", { detail: { ids } }),
|
| 104 |
-
);
|
| 105 |
-
}
|
| 106 |
|
| 107 |
export function useGenerationJobs() {
|
| 108 |
const { data: session } = authClient.useSession();
|
| 109 |
const isAuthenticated = !!session;
|
| 110 |
|
| 111 |
-
const
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
writeStoredIds(ids);
|
| 126 |
-
broadcastJobs(ids);
|
| 127 |
-
return ids;
|
| 128 |
-
});
|
| 129 |
-
},
|
| 130 |
-
[],
|
| 131 |
-
);
|
| 132 |
-
|
| 133 |
-
const addJob = useCallback(
|
| 134 |
-
(id: string) => {
|
| 135 |
-
setJobIds((prev) => {
|
| 136 |
-
if (prev.includes(id)) return prev;
|
| 137 |
-
return [...prev, id];
|
| 138 |
-
});
|
| 139 |
-
},
|
| 140 |
-
[setJobIds],
|
| 141 |
-
);
|
| 142 |
-
|
| 143 |
-
const removeJob = useCallback(
|
| 144 |
-
(id: string) => {
|
| 145 |
-
removedJobIdsRef.current.add(id);
|
| 146 |
-
|
| 147 |
-
// Sync sessionStorage immediately so removal survives unmount
|
| 148 |
-
// (React state updater callbacks & effects are discarded on unmount)
|
| 149 |
-
writeStoredIds(readStoredIds().filter((j) => j !== id));
|
| 150 |
-
writeStoredResults(readStoredResults().filter((r) => r.jobId !== id));
|
| 151 |
-
const cleared = readClearedJobIds();
|
| 152 |
-
if (!cleared.includes(id)) writeClearedJobIds([...cleared, id]);
|
| 153 |
-
|
| 154 |
-
setJobIds((prev) => prev.filter((j) => j !== id));
|
| 155 |
-
setCompletedResults((prev) => prev.filter((r) => r.jobId !== id));
|
| 156 |
-
trpcClient.ai.cancelJob.mutate({ jobId: id }).catch(() => {});
|
| 157 |
-
},
|
| 158 |
-
[setJobIds],
|
| 159 |
-
);
|
| 160 |
-
|
| 161 |
-
const resetAll = useCallback(() => {
|
| 162 |
-
setError(null);
|
| 163 |
-
|
| 164 |
-
// Sync: clear sessionStorage immediately (survives unmount)
|
| 165 |
-
sessionStorage.removeItem(RESULTS_KEY);
|
| 166 |
-
sessionStorage.removeItem(STORAGE_KEY);
|
| 167 |
-
|
| 168 |
-
// Sync: merge current job IDs into cleared list (accumulate, don't overwrite)
|
| 169 |
-
const current = readStoredIds();
|
| 170 |
-
const existing = readClearedJobIds();
|
| 171 |
-
writeClearedJobIds([...new Set([...existing, ...current])]);
|
| 172 |
-
|
| 173 |
-
// Keep blocks in-memory, don't clear
|
| 174 |
-
for (const id of current) removedJobIdsRef.current.add(id);
|
| 175 |
-
|
| 176 |
-
setCompletedResults([]);
|
| 177 |
-
setJobIds([]);
|
| 178 |
-
processedJobStates.current = {};
|
| 179 |
-
}, [setJobIds]);
|
| 180 |
-
|
| 181 |
-
// Recover from sessionStorage on mount
|
| 182 |
-
useEffect(() => {
|
| 183 |
-
const saved = readStoredIds();
|
| 184 |
-
if (saved.length > 0) setJobIdsState(saved);
|
| 185 |
-
const savedResults = readStoredResults();
|
| 186 |
-
if (savedResults.length > 0) setCompletedResults(savedResults);
|
| 187 |
-
|
| 188 |
-
const clearedIds = readClearedJobIds();
|
| 189 |
-
for (const id of clearedIds) {
|
| 190 |
-
removedJobIdsRef.current.add(id);
|
| 191 |
-
}
|
| 192 |
-
}, []);
|
| 193 |
-
|
| 194 |
-
// Persist completedResults to sessionStorage
|
| 195 |
-
useEffect(() => {
|
| 196 |
-
writeStoredResults(completedResults);
|
| 197 |
-
}, [completedResults]);
|
| 198 |
-
|
| 199 |
-
// Sync across hook instances via custom event
|
| 200 |
-
useEffect(() => {
|
| 201 |
-
const handler = (e: Event) => {
|
| 202 |
-
const detail = (e as CustomEvent).detail as { ids: string[] };
|
| 203 |
-
setJobIdsState(detail.ids);
|
| 204 |
-
};
|
| 205 |
-
window.addEventListener("labas:jobs-change", handler);
|
| 206 |
-
return () => window.removeEventListener("labas:jobs-change", handler);
|
| 207 |
-
}, []);
|
| 208 |
|
| 209 |
-
/
|
| 210 |
const myJobsQuery = useQuery({
|
| 211 |
...trpc.ai.myJobs.queryOptions({ limit: 20, offset: 0 }),
|
| 212 |
enabled: isAuthenticated,
|
| 213 |
refetchInterval: 3000,
|
| 214 |
});
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
const clearedIds = readClearedJobIds();
|
| 220 |
-
|
| 221 |
-
const activeIds = myJobsQuery.data
|
| 222 |
-
.filter(
|
| 223 |
-
(j) =>
|
| 224 |
-
ACTIVE_STATUSES.has(j.status) &&
|
| 225 |
-
!removedJobIdsRef.current.has(j.id) &&
|
| 226 |
-
!clearedIds.includes(j.id),
|
| 227 |
-
)
|
| 228 |
-
.map((j) => j.id);
|
| 229 |
-
if (activeIds.length === 0) return;
|
| 230 |
-
|
| 231 |
-
setJobIds((prev) => {
|
| 232 |
-
const newIds = activeIds.filter((id: string) => !prev.includes(id));
|
| 233 |
-
if (newIds.length === 0) return prev;
|
| 234 |
-
return [...prev, ...newIds];
|
| 235 |
-
});
|
| 236 |
-
}, [myJobsQuery.data, setJobIds]);
|
| 237 |
-
|
| 238 |
-
// Poll each active job individually
|
| 239 |
-
const jobQueries = useQueries({
|
| 240 |
-
queries: jobIds.map((jobId) => ({
|
| 241 |
-
...trpc.ai.getJobStatus.queryOptions({ jobId }),
|
| 242 |
-
enabled: !!jobId && isAuthenticated,
|
| 243 |
-
refetchInterval: (query: any) => {
|
| 244 |
-
const data = query.state.data;
|
| 245 |
-
if (!data) return 1000;
|
| 246 |
-
if (isTerminal(data.status as string)) return false;
|
| 247 |
-
return 1000;
|
| 248 |
-
},
|
| 249 |
-
})),
|
| 250 |
-
});
|
| 251 |
-
|
| 252 |
-
const activeJobs = useMemo(() => {
|
| 253 |
-
return jobQueries
|
| 254 |
-
.filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
|
| 255 |
-
.map((q) => q.data as unknown as ActiveJob);
|
| 256 |
-
}, [jobQueries]);
|
| 257 |
-
|
| 258 |
-
const isGenerating = activeJobs.length > 0;
|
| 259 |
-
const activeCount = activeJobs.length;
|
| 260 |
-
const canAddMore = activeCount < MAX_PARALLEL;
|
| 261 |
|
| 262 |
-
// Track completed results
|
| 263 |
useEffect(() => {
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
if (!jobId || !query.data) continue;
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
const prevStatus =
|
| 273 |
-
if (prevStatus === status)
|
| 274 |
-
|
| 275 |
|
| 276 |
const extractPkgId = (): string | null => {
|
| 277 |
const rj = data.resultJson as Record<string, unknown> | null | undefined;
|
|
@@ -280,99 +57,88 @@ export function useGenerationJobs() {
|
|
| 280 |
return typeof id === "string" ? id : null;
|
| 281 |
};
|
| 282 |
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
if (status === "completed" && data.resultJson) {
|
| 287 |
-
|
| 288 |
-
prev.map((r) =>
|
| 289 |
-
r.jobId === jobId
|
| 290 |
-
? {
|
| 291 |
-
jobId,
|
| 292 |
-
result: data.resultJson as GenerationResult,
|
| 293 |
-
generatedPackageId: extractPkgId(),
|
| 294 |
-
mode: (data.mode as string) ?? "quick",
|
| 295 |
-
timestamp: Date.now(),
|
| 296 |
-
}
|
| 297 |
-
: r,
|
| 298 |
-
),
|
| 299 |
-
);
|
| 300 |
}
|
| 301 |
-
|
| 302 |
}
|
| 303 |
|
| 304 |
-
// Partial ready or completed β capture result
|
| 305 |
if (status === "partial_ready" && data.resultJson) {
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
...prev,
|
| 310 |
-
{
|
| 311 |
-
jobId,
|
| 312 |
-
result: data.resultJson as GenerationResult,
|
| 313 |
-
generatedPackageId: extractPkgId(),
|
| 314 |
-
mode: (data.mode as string) ?? "quick",
|
| 315 |
-
timestamp: Date.now(),
|
| 316 |
-
},
|
| 317 |
-
];
|
| 318 |
-
});
|
| 319 |
}
|
| 320 |
|
| 321 |
if (status === "completed" && data.resultJson) {
|
| 322 |
-
|
| 323 |
-
const exists = prev.findIndex((r) => r.jobId === jobId);
|
| 324 |
-
const entry: CompletedResult = {
|
| 325 |
-
jobId,
|
| 326 |
-
result: data.resultJson as GenerationResult,
|
| 327 |
-
generatedPackageId: extractPkgId(),
|
| 328 |
-
mode: (data.mode as string) ?? "quick",
|
| 329 |
-
timestamp: Date.now(),
|
| 330 |
-
};
|
| 331 |
-
if (exists >= 0) {
|
| 332 |
-
const updated = [...prev];
|
| 333 |
-
updated[exists] = entry;
|
| 334 |
-
return updated;
|
| 335 |
-
}
|
| 336 |
-
return [...prev, entry];
|
| 337 |
-
});
|
| 338 |
}
|
| 339 |
|
| 340 |
if (status === "failed") {
|
| 341 |
setError((data.errorMessage as string) ?? "Generation failed");
|
| 342 |
-
|
| 343 |
-
prev.filter((r) => r.jobId !== jobId),
|
| 344 |
-
);
|
| 345 |
}
|
| 346 |
|
| 347 |
if (status === "cancelled") {
|
| 348 |
-
|
| 349 |
-
setCompletedResults((prev) =>
|
| 350 |
-
prev.filter((r) => r.jobId !== jobId),
|
| 351 |
-
);
|
| 352 |
}
|
| 353 |
-
}
|
| 354 |
-
}, [
|
| 355 |
|
| 356 |
-
/
|
| 357 |
useEffect(() => {
|
| 358 |
-
|
|
|
|
|
|
|
| 359 |
.filter(
|
| 360 |
-
(
|
| 361 |
-
|
|
|
|
|
|
|
| 362 |
)
|
| 363 |
-
.map((
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
);
|
| 373 |
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
|
| 377 |
return {
|
| 378 |
jobIds,
|
|
@@ -383,7 +149,7 @@ export function useGenerationJobs() {
|
|
| 383 |
canAddMore,
|
| 384 |
error,
|
| 385 |
addJob,
|
| 386 |
-
removeJob,
|
| 387 |
resetAll,
|
| 388 |
setError,
|
| 389 |
myJobsQuery,
|
|
|
|
| 1 |
+
import { useEffect, useCallback } from "react";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
import { trpc, trpcClient } from "@/utils/trpc";
|
|
|
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
+
import { useJobState } from "./use-job-state";
|
| 6 |
+
import { usePollingTransport } from "./use-polling-transport";
|
| 7 |
+
import { MAX_PARALLEL, ACTIVE_STATUSES, isTerminal } from "./use-job-shared";
|
| 8 |
+
import type { GenerationResult } from "@labas/ai";
|
| 9 |
|
| 10 |
+
export type { ActiveJob, CompletedResult } from "./use-job-shared";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
export function useGenerationJobs() {
|
| 13 |
const { data: session } = authClient.useSession();
|
| 14 |
const isAuthenticated = !!session;
|
| 15 |
|
| 16 |
+
const {
|
| 17 |
+
jobIds,
|
| 18 |
+
completedResults,
|
| 19 |
+
error,
|
| 20 |
+
processedStates,
|
| 21 |
+
removedIds,
|
| 22 |
+
addJob,
|
| 23 |
+
removeJob,
|
| 24 |
+
resetAll,
|
| 25 |
+
setError,
|
| 26 |
+
trackStatus,
|
| 27 |
+
setResult,
|
| 28 |
+
clearResult,
|
| 29 |
+
} = useJobState();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
/* Fallback: discover active jobs from myJobs endpoint */
|
| 32 |
const myJobsQuery = useQuery({
|
| 33 |
...trpc.ai.myJobs.queryOptions({ limit: 20, offset: 0 }),
|
| 34 |
enabled: isAuthenticated,
|
| 35 |
refetchInterval: 3000,
|
| 36 |
});
|
| 37 |
|
| 38 |
+
/* Transport layer β polling now, swappable for WebSocket later */
|
| 39 |
+
const transport = usePollingTransport({ isAuthenticated });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
|
|
|
| 41 |
useEffect(() => {
|
| 42 |
+
transport.subscribe(jobIds);
|
| 43 |
+
return () => transport.unsubscribe();
|
| 44 |
+
}, [jobIds, transport]);
|
|
|
|
| 45 |
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
transport.onUpdate((event) => {
|
| 48 |
+
const { jobId, status, data } = event;
|
| 49 |
+
const prevStatus = processedStates[jobId];
|
| 50 |
+
if (prevStatus === status) return;
|
| 51 |
+
trackStatus(jobId, status);
|
| 52 |
|
| 53 |
const extractPkgId = (): string | null => {
|
| 54 |
const rj = data.resultJson as Record<string, unknown> | null | undefined;
|
|
|
|
| 57 |
return typeof id === "string" ? id : null;
|
| 58 |
};
|
| 59 |
|
| 60 |
+
const baseResult = {
|
| 61 |
+
jobId,
|
| 62 |
+
result: data.resultJson as GenerationResult,
|
| 63 |
+
generatedPackageId: extractPkgId(),
|
| 64 |
+
mode: (data.mode as string) ?? "quick",
|
| 65 |
+
timestamp: Date.now(),
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const alreadyCompleted = completedResults.some(
|
| 69 |
+
(r) => r.jobId === jobId && r.timestamp > 0,
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
if (alreadyCompleted) {
|
| 73 |
if (status === "completed" && data.resultJson) {
|
| 74 |
+
setResult(baseResult);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
+
return;
|
| 77 |
}
|
| 78 |
|
|
|
|
| 79 |
if (status === "partial_ready" && data.resultJson) {
|
| 80 |
+
if (!completedResults.some((r) => r.jobId === jobId)) {
|
| 81 |
+
setResult(baseResult);
|
| 82 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
if (status === "completed" && data.resultJson) {
|
| 86 |
+
setResult(baseResult);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
if (status === "failed") {
|
| 90 |
setError((data.errorMessage as string) ?? "Generation failed");
|
| 91 |
+
clearResult(jobId);
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
if (status === "cancelled") {
|
| 95 |
+
clearResult(jobId);
|
|
|
|
|
|
|
|
|
|
| 96 |
}
|
| 97 |
+
});
|
| 98 |
+
}, [transport, processedStates, completedResults, trackStatus, setResult, setError, clearResult]);
|
| 99 |
|
| 100 |
+
/* Merge discovered jobs from myJobs fallback */
|
| 101 |
useEffect(() => {
|
| 102 |
+
if (!myJobsQuery.data) return;
|
| 103 |
+
const clearedIds = new Set<string>();
|
| 104 |
+
const discovered = myJobsQuery.data
|
| 105 |
.filter(
|
| 106 |
+
(j) =>
|
| 107 |
+
ACTIVE_STATUSES.has(j.status) &&
|
| 108 |
+
!removedIds.has(j.id) &&
|
| 109 |
+
!clearedIds.has(j.id),
|
| 110 |
)
|
| 111 |
+
.map((j) => j.id);
|
| 112 |
+
for (const id of discovered) {
|
| 113 |
+
if (!jobIds.includes(id)) addJob(id);
|
| 114 |
+
}
|
| 115 |
+
}, [myJobsQuery.data, removedIds, jobIds, addJob]);
|
| 116 |
|
| 117 |
+
/* Auto-remove terminal jobs after delay */
|
| 118 |
+
useEffect(() => {
|
| 119 |
+
const timers = jobIds
|
| 120 |
+
.filter((id) => {
|
| 121 |
+
const ev = processedStates[id];
|
| 122 |
+
return ev && isTerminal(ev);
|
| 123 |
+
})
|
| 124 |
+
.map((id) =>
|
| 125 |
+
setTimeout(() => removeJob(id), 5000),
|
| 126 |
+
);
|
| 127 |
+
return () => timers.forEach(clearTimeout);
|
| 128 |
+
}, [processedStates, jobIds, removeJob]);
|
| 129 |
|
| 130 |
+
const activeJobs = transport.activeJobs;
|
| 131 |
+
const activeCount = activeJobs.length;
|
| 132 |
+
const canAddMore = activeCount < MAX_PARALLEL;
|
| 133 |
+
const isGenerating = activeCount > 0;
|
|
|
|
| 134 |
|
| 135 |
+
const wrappedRemoveJob = useCallback(
|
| 136 |
+
(id: string) => {
|
| 137 |
+
trpcClient.ai.cancelJob.mutate({ jobId: id }).catch(() => {});
|
| 138 |
+
removeJob(id);
|
| 139 |
+
},
|
| 140 |
+
[removeJob],
|
| 141 |
+
);
|
| 142 |
|
| 143 |
return {
|
| 144 |
jobIds,
|
|
|
|
| 149 |
canAddMore,
|
| 150 |
error,
|
| 151 |
addJob,
|
| 152 |
+
removeJob: wrappedRemoveJob,
|
| 153 |
resetAll,
|
| 154 |
setError,
|
| 155 |
myJobsQuery,
|
apps/web/src/hooks/use-job-shared.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Shared types and constants for job management hooks. */
|
| 2 |
+
|
| 3 |
+
export const STORAGE_KEY = "labas_active_jobs";
|
| 4 |
+
export const RESULTS_KEY = "labas_completed_results";
|
| 5 |
+
export const CLEARED_JOBS_KEY = "labas_cleared_jobs";
|
| 6 |
+
export const MAX_PARALLEL = 3;
|
| 7 |
+
|
| 8 |
+
export interface ActiveJob {
|
| 9 |
+
id: string;
|
| 10 |
+
status: string;
|
| 11 |
+
progress: number;
|
| 12 |
+
progressMessage: string;
|
| 13 |
+
mode: string;
|
| 14 |
+
logs: unknown[];
|
| 15 |
+
resultJson: unknown;
|
| 16 |
+
errorMessage?: string;
|
| 17 |
+
examTypeId?: string;
|
| 18 |
+
sectionTypeId?: string;
|
| 19 |
+
questionCount?: number;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export interface CompletedResult {
|
| 23 |
+
jobId: string;
|
| 24 |
+
result: import("@labas/ai").GenerationResult;
|
| 25 |
+
generatedPackageId: string | null;
|
| 26 |
+
examType?: string;
|
| 27 |
+
section?: string;
|
| 28 |
+
mode: string;
|
| 29 |
+
timestamp: number;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/** Event emitted by a JobTransport when a job's status changes. */
|
| 33 |
+
export interface JobTransportEvent {
|
| 34 |
+
jobId: string;
|
| 35 |
+
status: string;
|
| 36 |
+
data: Record<string, unknown>;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/** Abstract transport for receiving real-time job updates.
|
| 40 |
+
* Concrete adapters: PollingTransport (now), WebSocketTransport (future). */
|
| 41 |
+
export interface JobTransport {
|
| 42 |
+
/** Start listening for updates on the given job IDs. */
|
| 43 |
+
subscribe(jobIds: string[]): void;
|
| 44 |
+
/** Stop listening for updates. */
|
| 45 |
+
unsubscribe(): void;
|
| 46 |
+
/** Register a callback for status-change events. */
|
| 47 |
+
onUpdate(callback: (event: JobTransportEvent) => void): void;
|
| 48 |
+
/** Register a callback for transport-level errors. */
|
| 49 |
+
onError(callback: (error: Error) => void): void;
|
| 50 |
+
/** Active jobs derived from latest events. */
|
| 51 |
+
readonly activeJobs: ActiveJob[];
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export const ACTIVE_STATUSES = new Set([
|
| 55 |
+
"pending",
|
| 56 |
+
"running",
|
| 57 |
+
"running_fast",
|
| 58 |
+
"partial_ready",
|
| 59 |
+
"running_quality",
|
| 60 |
+
]);
|
| 61 |
+
|
| 62 |
+
export const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
| 63 |
+
|
| 64 |
+
export function isTerminal(status: string): boolean {
|
| 65 |
+
return TERMINAL_STATUSES.has(status);
|
| 66 |
+
}
|
apps/web/src/hooks/use-job-state.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useReducer, useRef } from "react";
|
| 2 |
+
import {
|
| 3 |
+
type CompletedResult,
|
| 4 |
+
STORAGE_KEY,
|
| 5 |
+
RESULTS_KEY,
|
| 6 |
+
CLEARED_JOBS_KEY,
|
| 7 |
+
} from "./use-job-shared";
|
| 8 |
+
|
| 9 |
+
/* ββ Pure storage helpers (testable seam) ββ */
|
| 10 |
+
|
| 11 |
+
function readStoredIds(): string[] {
|
| 12 |
+
try {
|
| 13 |
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
| 14 |
+
return raw ? (JSON.parse(raw) as string[]) : [];
|
| 15 |
+
} catch {
|
| 16 |
+
return [];
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function writeStoredIds(ids: string[]) {
|
| 21 |
+
if (ids.length === 0) sessionStorage.removeItem(STORAGE_KEY);
|
| 22 |
+
else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function readStoredResults(): CompletedResult[] {
|
| 26 |
+
try {
|
| 27 |
+
const raw = sessionStorage.getItem(RESULTS_KEY);
|
| 28 |
+
return raw ? (JSON.parse(raw) as CompletedResult[]) : [];
|
| 29 |
+
} catch {
|
| 30 |
+
return [];
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function writeStoredResults(results: CompletedResult[]) {
|
| 35 |
+
if (results.length === 0) sessionStorage.removeItem(RESULTS_KEY);
|
| 36 |
+
else sessionStorage.setItem(RESULTS_KEY, JSON.stringify(results));
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function readClearedJobIds(): string[] {
|
| 40 |
+
try {
|
| 41 |
+
const raw = sessionStorage.getItem(CLEARED_JOBS_KEY);
|
| 42 |
+
return raw ? (JSON.parse(raw) as string[]) : [];
|
| 43 |
+
} catch {
|
| 44 |
+
return [];
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function writeClearedJobIds(ids: string[]) {
|
| 49 |
+
if (ids.length === 0) sessionStorage.removeItem(CLEARED_JOBS_KEY);
|
| 50 |
+
else sessionStorage.setItem(CLEARED_JOBS_KEY, JSON.stringify(ids));
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function broadcastJobs(ids: string[]) {
|
| 54 |
+
window.dispatchEvent(
|
| 55 |
+
new CustomEvent("labas:jobs-change", { detail: { ids } }),
|
| 56 |
+
);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* ββ State shape ββ */
|
| 60 |
+
|
| 61 |
+
interface State {
|
| 62 |
+
jobIds: string[];
|
| 63 |
+
completedResults: CompletedResult[];
|
| 64 |
+
error: string | null;
|
| 65 |
+
processedStates: Record<string, string>;
|
| 66 |
+
removedIds: Set<string>;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
type Action =
|
| 70 |
+
| { type: "init"; jobIds: string[]; results: CompletedResult[]; clearedIds: string[] }
|
| 71 |
+
| { type: "addJob"; id: string }
|
| 72 |
+
| { type: "removeJob"; id: string }
|
| 73 |
+
| { type: "reset" }
|
| 74 |
+
| { type: "syncIds"; ids: string[] }
|
| 75 |
+
| { type: "trackStatus"; jobId: string; status: string }
|
| 76 |
+
| { type: "setResult"; result: CompletedResult }
|
| 77 |
+
| { type: "clearResult"; jobId: string }
|
| 78 |
+
| { type: "setError"; error: string | null };
|
| 79 |
+
|
| 80 |
+
function reducer(state: State, action: Action): State {
|
| 81 |
+
switch (action.type) {
|
| 82 |
+
case "init": {
|
| 83 |
+
const removed = new Set(action.clearedIds);
|
| 84 |
+
return {
|
| 85 |
+
...state,
|
| 86 |
+
jobIds: action.jobIds,
|
| 87 |
+
completedResults: action.results,
|
| 88 |
+
removedIds: removed,
|
| 89 |
+
};
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
case "addJob": {
|
| 93 |
+
if (state.jobIds.includes(action.id)) return state;
|
| 94 |
+
const next = [...state.jobIds, action.id];
|
| 95 |
+
writeStoredIds(next);
|
| 96 |
+
broadcastJobs(next);
|
| 97 |
+
return { ...state, jobIds: next };
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
case "removeJob": {
|
| 101 |
+
const nextIds = state.jobIds.filter((j) => j !== action.id);
|
| 102 |
+
const nextResults = state.completedResults.filter((r) => r.jobId !== action.id);
|
| 103 |
+
const nextRemoved = new Set(state.removedIds).add(action.id);
|
| 104 |
+
|
| 105 |
+
writeStoredIds(nextIds);
|
| 106 |
+
writeStoredResults(nextResults);
|
| 107 |
+
const cleared = readClearedJobIds();
|
| 108 |
+
if (!cleared.includes(action.id)) writeClearedJobIds([...cleared, action.id]);
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
...state,
|
| 112 |
+
jobIds: nextIds,
|
| 113 |
+
completedResults: nextResults,
|
| 114 |
+
removedIds: nextRemoved,
|
| 115 |
+
};
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
case "reset": {
|
| 119 |
+
const current = readStoredIds();
|
| 120 |
+
const existing = readClearedJobIds();
|
| 121 |
+
writeClearedJobIds([...new Set([...existing, ...current])]);
|
| 122 |
+
sessionStorage.removeItem(RESULTS_KEY);
|
| 123 |
+
sessionStorage.removeItem(STORAGE_KEY);
|
| 124 |
+
|
| 125 |
+
const nextRemoved = new Set(state.removedIds);
|
| 126 |
+
for (const id of current) nextRemoved.add(id);
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
...state,
|
| 130 |
+
jobIds: [],
|
| 131 |
+
completedResults: [],
|
| 132 |
+
error: null,
|
| 133 |
+
processedStates: {},
|
| 134 |
+
removedIds: nextRemoved,
|
| 135 |
+
};
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
case "syncIds": {
|
| 139 |
+
return { ...state, jobIds: action.ids };
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
case "trackStatus": {
|
| 143 |
+
return {
|
| 144 |
+
...state,
|
| 145 |
+
processedStates: { ...state.processedStates, [action.jobId]: action.status },
|
| 146 |
+
};
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
case "setResult": {
|
| 150 |
+
const idx = state.completedResults.findIndex((r) => r.jobId === action.result.jobId);
|
| 151 |
+
if (idx >= 0) {
|
| 152 |
+
const updated = [...state.completedResults];
|
| 153 |
+
updated[idx] = action.result;
|
| 154 |
+
return { ...state, completedResults: updated };
|
| 155 |
+
}
|
| 156 |
+
return { ...state, completedResults: [...state.completedResults, action.result] };
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
case "clearResult": {
|
| 160 |
+
return {
|
| 161 |
+
...state,
|
| 162 |
+
completedResults: state.completedResults.filter((r) => r.jobId !== action.jobId),
|
| 163 |
+
};
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
case "setError": {
|
| 167 |
+
return { ...state, error: action.error };
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
default:
|
| 171 |
+
return state;
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* ββ Hook ββ */
|
| 176 |
+
|
| 177 |
+
export function useJobState() {
|
| 178 |
+
const [state, dispatch] = useReducer(reducer, {
|
| 179 |
+
jobIds: [],
|
| 180 |
+
completedResults: [],
|
| 181 |
+
error: null,
|
| 182 |
+
processedStates: {},
|
| 183 |
+
removedIds: new Set<string>(),
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
const stateRef = useRef(state);
|
| 187 |
+
stateRef.current = state;
|
| 188 |
+
|
| 189 |
+
/* Recover from sessionStorage on mount */
|
| 190 |
+
useEffect(() => {
|
| 191 |
+
const saved = readStoredIds();
|
| 192 |
+
const savedResults = readStoredResults();
|
| 193 |
+
const clearedIds = readClearedJobIds();
|
| 194 |
+
dispatch({ type: "init", jobIds: saved, results: savedResults, clearedIds });
|
| 195 |
+
}, []);
|
| 196 |
+
|
| 197 |
+
/* Persist completedResults */
|
| 198 |
+
useEffect(() => {
|
| 199 |
+
writeStoredResults(state.completedResults);
|
| 200 |
+
}, [state.completedResults]);
|
| 201 |
+
|
| 202 |
+
/* Sync across hook instances via custom event */
|
| 203 |
+
useEffect(() => {
|
| 204 |
+
const handler = (e: Event) => {
|
| 205 |
+
const detail = (e as CustomEvent).detail as { ids: string[] };
|
| 206 |
+
dispatch({ type: "syncIds", ids: detail.ids });
|
| 207 |
+
};
|
| 208 |
+
window.addEventListener("labas:jobs-change", handler);
|
| 209 |
+
return () => window.removeEventListener("labas:jobs-change", handler);
|
| 210 |
+
}, []);
|
| 211 |
+
|
| 212 |
+
const addJob = useCallback((id: string) => dispatch({ type: "addJob", id }), []);
|
| 213 |
+
const removeJob = useCallback((id: string) => dispatch({ type: "removeJob", id }), []);
|
| 214 |
+
const resetAll = useCallback(() => dispatch({ type: "reset" }), []);
|
| 215 |
+
const setError = useCallback((error: string | null) => dispatch({ type: "setError", error }), []);
|
| 216 |
+
const trackStatus = useCallback(
|
| 217 |
+
(jobId: string, status: string) => dispatch({ type: "trackStatus", jobId, status }),
|
| 218 |
+
[],
|
| 219 |
+
);
|
| 220 |
+
const setResult = useCallback(
|
| 221 |
+
(result: CompletedResult) => dispatch({ type: "setResult", result }),
|
| 222 |
+
[],
|
| 223 |
+
);
|
| 224 |
+
const clearResult = useCallback(
|
| 225 |
+
(jobId: string) => dispatch({ type: "clearResult", jobId }),
|
| 226 |
+
[],
|
| 227 |
+
);
|
| 228 |
+
|
| 229 |
+
return {
|
| 230 |
+
jobIds: state.jobIds,
|
| 231 |
+
completedResults: state.completedResults,
|
| 232 |
+
error: state.error,
|
| 233 |
+
processedStates: state.processedStates,
|
| 234 |
+
removedIds: state.removedIds,
|
| 235 |
+
addJob,
|
| 236 |
+
removeJob,
|
| 237 |
+
resetAll,
|
| 238 |
+
setError,
|
| 239 |
+
trackStatus,
|
| 240 |
+
setResult,
|
| 241 |
+
clearResult,
|
| 242 |
+
};
|
| 243 |
+
}
|
apps/web/src/hooks/use-package-builder.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useMutation } from "@tanstack/react-query";
|
|
| 2 |
import { useNavigate } from "@tanstack/react-router";
|
| 3 |
import { trpc } from "@/utils/trpc";
|
| 4 |
import { toast } from "sonner";
|
|
|
|
| 5 |
|
| 6 |
export function usePackageBuilder() {
|
| 7 |
const navigate = useNavigate();
|
|
@@ -14,7 +15,7 @@ export function usePackageBuilder() {
|
|
| 14 |
|
| 15 |
const handleCreatePackage = async (params: {
|
| 16 |
selectedIds: Set<string>;
|
| 17 |
-
questions: Array<{ id: string; examTypeId: string; sectionTypeId: string; sectionTypeName?: string | null; [key: string]:
|
| 18 |
title: string;
|
| 19 |
description: string;
|
| 20 |
isPublic: boolean;
|
|
@@ -51,8 +52,8 @@ export function usePackageBuilder() {
|
|
| 51 |
}
|
| 52 |
|
| 53 |
navigate({ to: "/package/$id", params: { id: pkg.id } });
|
| 54 |
-
} catch (err:
|
| 55 |
-
toast.error("Gagal membuat paket", { description: err
|
| 56 |
}
|
| 57 |
};
|
| 58 |
|
|
@@ -106,8 +107,8 @@ export function usePackageBuilder() {
|
|
| 106 |
}
|
| 107 |
|
| 108 |
navigate({ to: "/package/$id", params: { id: pkg.id } });
|
| 109 |
-
} catch (err:
|
| 110 |
-
toast.error("Gagal membuat paket", { description: err
|
| 111 |
}
|
| 112 |
};
|
| 113 |
|
|
|
|
| 2 |
import { useNavigate } from "@tanstack/react-router";
|
| 3 |
import { trpc } from "@/utils/trpc";
|
| 4 |
import { toast } from "sonner";
|
| 5 |
+
import { getErrorMessage } from "@/lib/error-utils";
|
| 6 |
|
| 7 |
export function usePackageBuilder() {
|
| 8 |
const navigate = useNavigate();
|
|
|
|
| 15 |
|
| 16 |
const handleCreatePackage = async (params: {
|
| 17 |
selectedIds: Set<string>;
|
| 18 |
+
questions: Array<{ id: string; examTypeId: string; sectionTypeId: string; sectionTypeName?: string | null; [key: string]: unknown }>;
|
| 19 |
title: string;
|
| 20 |
description: string;
|
| 21 |
isPublic: boolean;
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
navigate({ to: "/package/$id", params: { id: pkg.id } });
|
| 55 |
+
} catch (err: unknown) {
|
| 56 |
+
toast.error("Gagal membuat paket", { description: getErrorMessage(err) });
|
| 57 |
}
|
| 58 |
};
|
| 59 |
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
navigate({ to: "/package/$id", params: { id: pkg.id } });
|
| 110 |
+
} catch (err: unknown) {
|
| 111 |
+
toast.error("Gagal membuat paket", { description: getErrorMessage(err) });
|
| 112 |
}
|
| 113 |
};
|
| 114 |
|
apps/web/src/hooks/use-polling-transport.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback, useRef } from "react";
|
| 2 |
+
import { useQueries } from "@tanstack/react-query";
|
| 3 |
+
import { trpc } from "@/utils/trpc";
|
| 4 |
+
import { type ActiveJob, type JobTransport, type JobTransportEvent, isTerminal } from "./use-job-shared";
|
| 5 |
+
|
| 6 |
+
/** useQueries + .map() breaks contextual typing for refetchInterval; accept `unknown` so the callback is a valid supertype of TanStack's `Query` parameter. */
|
| 7 |
+
function jobStatusRefetchInterval(query: unknown): number | false {
|
| 8 |
+
const data = (query as { state: { data: { status: string } | null | undefined } }).state.data;
|
| 9 |
+
if (data == null) return 1000;
|
| 10 |
+
if (isTerminal(data.status)) return false;
|
| 11 |
+
return 1000;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/** Polling-based implementation of JobTransport.
|
| 15 |
+
* Uses tRPC useQueries to poll each tracked job individually. */
|
| 16 |
+
export function usePollingTransport({
|
| 17 |
+
isAuthenticated,
|
| 18 |
+
}: {
|
| 19 |
+
isAuthenticated: boolean;
|
| 20 |
+
}): JobTransport {
|
| 21 |
+
const [jobIds, setJobIds] = useState<string[]>([]);
|
| 22 |
+
const [activeJobs, setActiveJobs] = useState<ActiveJob[]>([]);
|
| 23 |
+
const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
|
| 24 |
+
const onErrorRef = useRef<(error: Error) => void>(undefined);
|
| 25 |
+
|
| 26 |
+
const jobQueries = useQueries({
|
| 27 |
+
queries: jobIds.map((jobId) => ({
|
| 28 |
+
...trpc.ai.getJobStatus.queryOptions({ jobId }),
|
| 29 |
+
enabled: !!jobId && isAuthenticated,
|
| 30 |
+
refetchInterval: jobStatusRefetchInterval,
|
| 31 |
+
})),
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
const nextActive = jobQueries
|
| 36 |
+
.filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
|
| 37 |
+
.map((q) => q.data as unknown as ActiveJob);
|
| 38 |
+
setActiveJobs(nextActive);
|
| 39 |
+
|
| 40 |
+
// Emit events for status changes
|
| 41 |
+
for (let i = 0; i < jobQueries.length; i++) {
|
| 42 |
+
const query = jobQueries[i];
|
| 43 |
+
const jobId = jobIds[i];
|
| 44 |
+
if (!jobId || !query.data) continue;
|
| 45 |
+
const data = query.data as Record<string, unknown>;
|
| 46 |
+
onUpdateRef.current?.({
|
| 47 |
+
jobId,
|
| 48 |
+
status: data.status as string,
|
| 49 |
+
data,
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
}, [jobQueries, jobIds]);
|
| 53 |
+
|
| 54 |
+
const subscribe = useCallback((ids: string[]) => {
|
| 55 |
+
setJobIds(ids);
|
| 56 |
+
}, []);
|
| 57 |
+
|
| 58 |
+
const unsubscribe = useCallback(() => {
|
| 59 |
+
setJobIds([]);
|
| 60 |
+
setActiveJobs([]);
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
const onUpdate = useCallback((callback: (event: JobTransportEvent) => void) => {
|
| 64 |
+
onUpdateRef.current = callback;
|
| 65 |
+
}, []);
|
| 66 |
+
|
| 67 |
+
const onError = useCallback((callback: (error: Error) => void) => {
|
| 68 |
+
onErrorRef.current = callback;
|
| 69 |
+
}, []);
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
subscribe,
|
| 73 |
+
unsubscribe,
|
| 74 |
+
onUpdate,
|
| 75 |
+
onError,
|
| 76 |
+
get activeJobs() {
|
| 77 |
+
return activeJobs;
|
| 78 |
+
},
|
| 79 |
+
};
|
| 80 |
+
}
|
apps/web/src/hooks/use-question-selection.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { useState, useMemo } from "react";
|
|
| 3 |
interface Question {
|
| 4 |
id: string;
|
| 5 |
examTypeId: string;
|
| 6 |
-
[key: string]:
|
| 7 |
}
|
| 8 |
|
| 9 |
export function useQuestionSelection(questions: Question[]) {
|
|
|
|
| 3 |
interface Question {
|
| 4 |
id: string;
|
| 5 |
examTypeId: string;
|
| 6 |
+
[key: string]: unknown;
|
| 7 |
}
|
| 8 |
|
| 9 |
export function useQuestionSelection(questions: Question[]) {
|
apps/web/src/hooks/use-test-session.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|
| 2 |
import { useMutation, useQuery } from "@tanstack/react-query";
|
| 3 |
import { useNavigate } from "@tanstack/react-router";
|
| 4 |
import { trpc } from "@/utils/trpc";
|
|
|
|
| 5 |
|
| 6 |
function getTimerKey(attemptId: string | null) {
|
| 7 |
return attemptId ? `labas_attempt_timer_${attemptId}` : null;
|
|
@@ -168,9 +169,9 @@ export function useTestSession(packageId: string, existingAttemptId?: string) {
|
|
| 168 |
// Load any saved timer (in case of refresh during attempt)
|
| 169 |
const saved = loadElapsedTime(res.attemptId);
|
| 170 |
if (saved > 0) setTimeElapsed(saved);
|
| 171 |
-
} catch (err:
|
| 172 |
console.error("[useTestSession] Start attempt failed:", err);
|
| 173 |
-
setStartError(err
|
| 174 |
}
|
| 175 |
}, [packageId, startMutation]);
|
| 176 |
|
|
|
|
| 2 |
import { useMutation, useQuery } from "@tanstack/react-query";
|
| 3 |
import { useNavigate } from "@tanstack/react-router";
|
| 4 |
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { getErrorMessage } from "@/lib/error-utils";
|
| 6 |
|
| 7 |
function getTimerKey(attemptId: string | null) {
|
| 8 |
return attemptId ? `labas_attempt_timer_${attemptId}` : null;
|
|
|
|
| 169 |
// Load any saved timer (in case of refresh during attempt)
|
| 170 |
const saved = loadElapsedTime(res.attemptId);
|
| 171 |
if (saved > 0) setTimeElapsed(saved);
|
| 172 |
+
} catch (err: unknown) {
|
| 173 |
console.error("[useTestSession] Start attempt failed:", err);
|
| 174 |
+
setStartError(getErrorMessage(err));
|
| 175 |
}
|
| 176 |
}, [packageId, startMutation]);
|
| 177 |
|
apps/web/src/lib/types.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Shared frontend types inferred from tRPC outputs.
|
| 2 |
+
* These are lightweight type aliases for data shapes used across components.
|
| 3 |
+
* For strict typing, prefer tRPC inferred types: RouterOutputs['router']['procedure'] */
|
| 4 |
+
|
| 5 |
+
export interface Question {
|
| 6 |
+
id: string;
|
| 7 |
+
examTypeId?: string;
|
| 8 |
+
sectionTypeId?: string;
|
| 9 |
+
format?: string;
|
| 10 |
+
passageText?: string | null;
|
| 11 |
+
questionText?: string;
|
| 12 |
+
options?: unknown;
|
| 13 |
+
correctAnswer?: string | null;
|
| 14 |
+
explanation?: string | null;
|
| 15 |
+
difficulty?: number | null;
|
| 16 |
+
isPublic?: boolean;
|
| 17 |
+
creatorUserId?: string | null;
|
| 18 |
+
examTypeName?: string | null;
|
| 19 |
+
sectionTypeName?: string | null;
|
| 20 |
+
skillTags?: string[] | null;
|
| 21 |
+
usageCount?: number;
|
| 22 |
+
avgRating?: number | null;
|
| 23 |
+
creatorName?: string | null;
|
| 24 |
+
source?: string | null;
|
| 25 |
+
_examType?: string;
|
| 26 |
+
_isRtl?: boolean;
|
| 27 |
+
_useFurigana?: boolean;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export interface Package {
|
| 31 |
+
id: string;
|
| 32 |
+
title?: string;
|
| 33 |
+
description?: string | null;
|
| 34 |
+
examTypeId?: string;
|
| 35 |
+
examTypeName?: string | null;
|
| 36 |
+
creatorUserId?: string | null;
|
| 37 |
+
creatorName?: string | null;
|
| 38 |
+
isPublic?: boolean;
|
| 39 |
+
totalQuestions?: number;
|
| 40 |
+
totalSections?: number;
|
| 41 |
+
estimatedDurationMin?: number | null;
|
| 42 |
+
usageCount?: number;
|
| 43 |
+
avgRating?: number | null;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export interface PackageSection {
|
| 47 |
+
id: string;
|
| 48 |
+
title?: string;
|
| 49 |
+
questions?: Question[];
|
| 50 |
+
packageId?: string;
|
| 51 |
+
packageTitle?: string | null;
|
| 52 |
+
examTypeName?: string | null;
|
| 53 |
+
sectionTypeName?: string | null;
|
| 54 |
+
sectionTypeId?: string;
|
| 55 |
+
orderIndex?: number;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export interface AttemptSection {
|
| 59 |
+
id: string;
|
| 60 |
+
title?: string;
|
| 61 |
+
questions?: Question[];
|
| 62 |
+
answers?: Array<{
|
| 63 |
+
questionId: string;
|
| 64 |
+
userAnswer?: string;
|
| 65 |
+
answer?: string;
|
| 66 |
+
isCorrect?: boolean | null;
|
| 67 |
+
partialScore?: number | null;
|
| 68 |
+
timeSpentSec?: number;
|
| 69 |
+
}>;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export interface Attempt {
|
| 73 |
+
id: string;
|
| 74 |
+
packageId?: string;
|
| 75 |
+
status?: string;
|
| 76 |
+
score?: number | null;
|
| 77 |
+
sections?: AttemptSection[];
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export interface JobItem {
|
| 81 |
+
id: string;
|
| 82 |
+
status: string;
|
| 83 |
+
examTypeId?: string;
|
| 84 |
+
sectionTypeId?: string;
|
| 85 |
+
format?: string;
|
| 86 |
+
}
|
apps/web/src/routes/__root.tsx
CHANGED
|
@@ -11,6 +11,17 @@ import { GlobalGenerationProgress } from "@/components/generate/GlobalGeneration
|
|
| 11 |
import { ErrorFallback } from "@/components/ErrorFallback";
|
| 12 |
import type { trpc } from "@/utils/trpc";
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import "../index.css";
|
| 15 |
|
| 16 |
export interface RouterAppContext {
|
|
@@ -70,8 +81,10 @@ function RootComponent() {
|
|
| 70 |
</div>
|
| 71 |
) : (
|
| 72 |
<div className="min-h-screen bg-background relative">
|
|
|
|
| 73 |
<Sidebar />
|
| 74 |
<main
|
|
|
|
| 75 |
className={`min-h-screen transition-all duration-300 relative z-0 ${collapsed ? "md:ml-16" : "md:ml-64"}`}
|
| 76 |
>
|
| 77 |
<Outlet />
|
|
|
|
| 11 |
import { ErrorFallback } from "@/components/ErrorFallback";
|
| 12 |
import type { trpc } from "@/utils/trpc";
|
| 13 |
|
| 14 |
+
function SkipLink() {
|
| 15 |
+
return (
|
| 16 |
+
<a
|
| 17 |
+
href="#main-content"
|
| 18 |
+
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-[var(--clay-black)] focus:text-[var(--pure-white)] focus:rounded-[var(--radius-lg)] focus:font-semibold focus:text-sm focus:shadow-xl"
|
| 19 |
+
>
|
| 20 |
+
Lewati ke konten utama
|
| 21 |
+
</a>
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
import "../index.css";
|
| 26 |
|
| 27 |
export interface RouterAppContext {
|
|
|
|
| 81 |
</div>
|
| 82 |
) : (
|
| 83 |
<div className="min-h-screen bg-background relative">
|
| 84 |
+
<SkipLink />
|
| 85 |
<Sidebar />
|
| 86 |
<main
|
| 87 |
+
id="main-content"
|
| 88 |
className={`min-h-screen transition-all duration-300 relative z-0 ${collapsed ? "md:ml-16" : "md:ml-64"}`}
|
| 89 |
>
|
| 90 |
<Outlet />
|
apps/web/src/routes/admin.credits.tsx
CHANGED
|
@@ -20,6 +20,12 @@ function AdminCredits() {
|
|
| 20 |
const [description, setDescription] = useState("");
|
| 21 |
const queryClient = useQueryClient();
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const userSearch = useQuery(
|
| 24 |
trpc.admin.listUsers.queryOptions(
|
| 25 |
{ search: debouncedSearch || undefined, limit: 10, offset: 0 },
|
|
@@ -81,6 +87,7 @@ function AdminCredits() {
|
|
| 81 |
placeholder="Search by name or email..."
|
| 82 |
value={search}
|
| 83 |
onChange={(e) => handleSearch(e.target.value)}
|
|
|
|
| 84 |
className="max-w-md"
|
| 85 |
/>
|
| 86 |
{userSearch.data && userSearch.data.users.length > 0 && !selectedUserId && (
|
|
|
|
| 20 |
const [description, setDescription] = useState("");
|
| 21 |
const queryClient = useQueryClient();
|
| 22 |
|
| 23 |
+
function handleSearch(val: string) {
|
| 24 |
+
setSearch(val);
|
| 25 |
+
setSelectedUserId("");
|
| 26 |
+
setSelectedUserName("");
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
const userSearch = useQuery(
|
| 30 |
trpc.admin.listUsers.queryOptions(
|
| 31 |
{ search: debouncedSearch || undefined, limit: 10, offset: 0 },
|
|
|
|
| 87 |
placeholder="Search by name or email..."
|
| 88 |
value={search}
|
| 89 |
onChange={(e) => handleSearch(e.target.value)}
|
| 90 |
+
aria-label="Cari user"
|
| 91 |
className="max-w-md"
|
| 92 |
/>
|
| 93 |
{userSearch.data && userSearch.data.users.length > 0 && !selectedUserId && (
|
apps/web/src/routes/admin.featured.tsx
CHANGED
|
@@ -4,9 +4,11 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
| 4 |
import { trpc } from "@/utils/trpc";
|
| 5 |
import { Input } from "@labas/ui/components/input";
|
| 6 |
import { Button } from "@labas/ui/components/button";
|
|
|
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
|
|
| 10 |
|
| 11 |
export const Route = createFileRoute("/admin/featured")({
|
| 12 |
component: AdminFeatured,
|
|
@@ -67,128 +69,163 @@ function AdminFeatured() {
|
|
| 67 |
|
| 68 |
return (
|
| 69 |
<div>
|
| 70 |
-
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Editor
|
| 71 |
<p className="text-[var(--warm-charcoal)] mb-6">Curate featured content for the homepage.</p>
|
| 72 |
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
</div>
|
| 96 |
-
{fPackages.length === 0 ? (
|
| 97 |
-
<p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
|
| 98 |
-
) : (
|
| 99 |
-
<div className="space-y-2">
|
| 100 |
-
{fPackages.map((pkg) => (
|
| 101 |
-
<div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 102 |
-
<div>
|
| 103 |
-
<p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
|
| 104 |
-
<p className="text-xs text-[var(--warm-charcoal)]">{pkg.examTypeId}</p>
|
| 105 |
-
</div>
|
| 106 |
-
<Button variant="outline" onClick={() => togglePkgMutation.mutate({ packageId: pkg.id })} disabled={togglePkgMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">Unfeature</Button>
|
| 107 |
-
</div>
|
| 108 |
-
))}
|
| 109 |
</div>
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
<div className="space-y-2">
|
| 122 |
-
{fQuestions.map((q) => (
|
| 123 |
-
<div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 124 |
-
<div className="flex-1 min-w-0">
|
| 125 |
-
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
|
| 126 |
-
<p className="text-xs text-[var(--warm-charcoal)]">{q.format} Β· {q.examTypeId}</p>
|
| 127 |
</div>
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
|
| 138 |
-
<div>
|
| 139 |
<Input
|
| 140 |
-
placeholder=
|
| 141 |
value={search}
|
| 142 |
onChange={(e) => handleSearch(e.target.value)}
|
| 143 |
className="max-w-md mb-4"
|
| 144 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
</p>
|
| 153 |
-
<
|
| 154 |
-
{
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
))}
|
| 180 |
-
</div>
|
| 181 |
-
{searchQuery.data && (searchQuery.data.total as number) > limit && (
|
| 182 |
-
<div className="flex items-center justify-center gap-4 mt-6">
|
| 183 |
-
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
|
| 184 |
-
<span className="text-sm text-[var(--warm-charcoal)]">Page {page} of {Math.ceil((searchQuery.data.total as number) / limit)}</span>
|
| 185 |
-
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page * limit >= (searchQuery.data.total as number)}>Next</Button>
|
| 186 |
-
</div>
|
| 187 |
-
)}
|
| 188 |
-
</>
|
| 189 |
-
)}
|
| 190 |
</div>
|
| 191 |
)}
|
| 192 |
-
</
|
| 193 |
);
|
| 194 |
-
}
|
|
|
|
| 4 |
import { trpc } from "@/utils/trpc";
|
| 5 |
import { Input } from "@labas/ui/components/input";
|
| 6 |
import { Button } from "@labas/ui/components/button";
|
| 7 |
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@labas/ui/components/tabs";
|
| 8 |
import { toast } from "sonner";
|
| 9 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 10 |
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
| 11 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 12 |
|
| 13 |
export const Route = createFileRoute("/admin/featured")({
|
| 14 |
component: AdminFeatured,
|
|
|
|
| 69 |
|
| 70 |
return (
|
| 71 |
<div>
|
| 72 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Editor's Pick</h1>
|
| 73 |
<p className="text-[var(--warm-charcoal)] mb-6">Curate featured content for the homepage.</p>
|
| 74 |
|
| 75 |
+
<Tabs
|
| 76 |
+
value={tab}
|
| 77 |
+
onValueChange={(v) => { setTab(v as Tab); setSearch(""); setPage(1); }}
|
| 78 |
+
className="mb-6"
|
| 79 |
+
>
|
| 80 |
+
<TabsList variant="line" className="h-10 bg-transparent border-b border-[var(--oat-border)]">
|
| 81 |
+
{tabs.map((t) => (
|
| 82 |
+
<TabsTrigger
|
| 83 |
+
key={t.key}
|
| 84 |
+
value={t.key}
|
| 85 |
+
className="px-4 py-2.5 text-sm font-medium rounded-t-[var(--radius-lg)] data-[active=true]:bg-[var(--pure-white)] data-[active=true]:text-[var(--clay-black)] data-[active=true]:border data-[active=true]:border-[var(--oat-border)] data-[active=true]:border-b-[var(--pure-white)] data-[active=true]:-mb-[1px] text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
|
| 86 |
+
>
|
| 87 |
+
{t.label}
|
| 88 |
+
</TabsTrigger>
|
| 89 |
+
))}
|
| 90 |
+
</TabsList>
|
| 91 |
|
| 92 |
+
<TabsContent value="featured" className="mt-6">
|
| 93 |
+
<div className="space-y-8">
|
| 94 |
+
<section>
|
| 95 |
+
<div className="flex items-center gap-2 mb-4">
|
| 96 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Packages</h2>
|
| 97 |
+
<span className="text-sm text-[var(--warm-charcoal)]">({fPackages.length})</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
+
{fPackages.length === 0 ? (
|
| 100 |
+
<p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
|
| 101 |
+
) : (
|
| 102 |
+
<div className="space-y-2">
|
| 103 |
+
{fPackages.map((pkg) => (
|
| 104 |
+
<div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 105 |
+
<div>
|
| 106 |
+
<p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
|
| 107 |
+
<p className="text-xs text-[var(--warm-charcoal)]">{pkg.examTypeId}</p>
|
| 108 |
+
</div>
|
| 109 |
+
<Button variant="outline" onClick={() => togglePkgMutation.mutate({ packageId: pkg.id })} disabled={togglePkgMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">Unfeature</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
</div>
|
| 111 |
+
))}
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</section>
|
| 115 |
+
|
| 116 |
+
<section>
|
| 117 |
+
<div className="flex items-center gap-2 mb-4">
|
| 118 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Questions</h2>
|
| 119 |
+
<span className="text-sm text-[var(--warm-charcoal)]">({fQuestions.length})</span>
|
| 120 |
</div>
|
| 121 |
+
{fQuestions.length === 0 ? (
|
| 122 |
+
<p className="text-sm text-[var(--warm-charcoal)] py-4">No featured questions.</p>
|
| 123 |
+
) : (
|
| 124 |
+
<div className="space-y-2">
|
| 125 |
+
{fQuestions.map((q) => (
|
| 126 |
+
<div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 127 |
+
<div className="flex-1 min-w-0">
|
| 128 |
+
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
|
| 129 |
+
<p className="text-xs text-[var(--warm-charcoal)]">{q.format} Β· {q.examTypeId}</p>
|
| 130 |
+
</div>
|
| 131 |
+
<Button variant="outline" className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleQMutation.mutate({ questionId: q.id })} disabled={toggleQMutation.isPending}>Unfeature</Button>
|
| 132 |
+
</div>
|
| 133 |
+
))}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</section>
|
| 137 |
+
</div>
|
| 138 |
+
</TabsContent>
|
| 139 |
+
|
| 140 |
+
<TabsContent value="packages" className="mt-6">
|
| 141 |
+
<div className="relative flex-1">
|
| 142 |
+
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 143 |
+
<Input
|
| 144 |
+
placeholder="Search packages..."
|
| 145 |
+
value={search}
|
| 146 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 147 |
+
aria-label="Search packages"
|
| 148 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 149 |
+
/>
|
| 150 |
+
</div>
|
| 151 |
+
<SearchResults query={searchQuery} tab="packages" page={page} setPage={setPage} limit={limit} togglePkgMutation={togglePkgMutation} toggleQMutation={toggleQMutation} />
|
| 152 |
+
</TabsContent>
|
| 153 |
|
| 154 |
+
<TabsContent value="questions" className="mt-6">
|
|
|
|
| 155 |
<Input
|
| 156 |
+
placeholder="Search questions..."
|
| 157 |
value={search}
|
| 158 |
onChange={(e) => handleSearch(e.target.value)}
|
| 159 |
className="max-w-md mb-4"
|
| 160 |
/>
|
| 161 |
+
<SearchResults query={searchQuery} tab="questions" page={page} setPage={setPage} limit={limit} togglePkgMutation={togglePkgMutation} toggleQMutation={toggleQMutation} />
|
| 162 |
+
</TabsContent>
|
| 163 |
+
</Tabs>
|
| 164 |
+
</div>
|
| 165 |
+
);
|
| 166 |
+
}
|
| 167 |
|
| 168 |
+
function SearchResults({
|
| 169 |
+
query,
|
| 170 |
+
tab,
|
| 171 |
+
page,
|
| 172 |
+
setPage,
|
| 173 |
+
limit,
|
| 174 |
+
togglePkgMutation,
|
| 175 |
+
toggleQMutation,
|
| 176 |
+
}: {
|
| 177 |
+
query: ReturnType<typeof useQuery>;
|
| 178 |
+
tab: "packages" | "questions";
|
| 179 |
+
page: number;
|
| 180 |
+
setPage: (fn: (p: number) => number) => void;
|
| 181 |
+
limit: number;
|
| 182 |
+
togglePkgMutation: { mutate: (args: { packageId: string }) => void; isPending: boolean };
|
| 183 |
+
toggleQMutation: { mutate: (args: { questionId: string }) => void; isPending: boolean };
|
| 184 |
+
}) {
|
| 185 |
+
if (query.isLoading) {
|
| 186 |
+
return <p className="text-sm text-[var(--warm-charcoal)] py-8 text-center">Searching...</p>;
|
| 187 |
+
}
|
| 188 |
+
const data = query.data as { items: Array<{ id: string; title?: string; questionText?: string; format?: string; examTypeId?: string; isFeatured?: boolean }>; total: number } | undefined;
|
| 189 |
+
return (
|
| 190 |
+
<>
|
| 191 |
+
<p className="text-sm text-[var(--warm-charcoal)] mb-3">
|
| 192 |
+
{query.isSuccess ? `Showing ${data?.total ?? 0} ${tab}` : ""}
|
| 193 |
+
</p>
|
| 194 |
+
<div className="space-y-2">
|
| 195 |
+
{(data?.items ?? []).map((item) => (
|
| 196 |
+
<div key={item.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 197 |
+
<div className="flex-1 min-w-0">
|
| 198 |
+
<p className="font-medium text-[var(--clay-black)] truncate">
|
| 199 |
+
{item.title ?? item.questionText}
|
| 200 |
</p>
|
| 201 |
+
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 202 |
+
{item.format ? `${item.format} Β· ${item.examTypeId}` : item.examTypeId}
|
| 203 |
+
{item.isFeatured && (
|
| 204 |
+
<span className="ml-2 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]">Featured</span>
|
| 205 |
+
)}
|
| 206 |
+
</p>
|
| 207 |
+
</div>
|
| 208 |
+
<Button
|
| 209 |
+
variant={item.isFeatured ? "outline" : "default"}
|
| 210 |
+
className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs"
|
| 211 |
+
onClick={() => {
|
| 212 |
+
if (tab === "packages") togglePkgMutation.mutate({ packageId: item.id });
|
| 213 |
+
else toggleQMutation.mutate({ questionId: item.id });
|
| 214 |
+
}}
|
| 215 |
+
disabled={togglePkgMutation.isPending || toggleQMutation.isPending}
|
| 216 |
+
>
|
| 217 |
+
{item.isFeatured ? "Unfeature" : "Feature"}
|
| 218 |
+
</Button>
|
| 219 |
+
</div>
|
| 220 |
+
))}
|
| 221 |
+
</div>
|
| 222 |
+
{data && data.total > limit && (
|
| 223 |
+
<div className="flex items-center justify-center gap-4 mt-6">
|
| 224 |
+
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
|
| 225 |
+
<span className="text-sm text-[var(--warm-charcoal)]">Page {page} of {Math.ceil(data.total / limit)}</span>
|
| 226 |
+
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page * limit >= data.total}>Next</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
</div>
|
| 228 |
)}
|
| 229 |
+
</>
|
| 230 |
);
|
| 231 |
+
}
|
apps/web/src/routes/admin.jobs.tsx
CHANGED
|
@@ -80,7 +80,7 @@ function AdminJobs() {
|
|
| 80 |
) : (!jobs.data?.jobs || jobs.data.jobs.length === 0) ? (
|
| 81 |
<tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">No jobs found.</td></tr>
|
| 82 |
) : (
|
| 83 |
-
jobs.data.jobs.map((job
|
| 84 |
<tr key={job.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 85 |
<td className="px-4 py-3 text-[var(--warm-charcoal)] font-mono text-xs">{job.userId?.slice(0, 10)}...</td>
|
| 86 |
<td className="px-4 py-3">{job.examTypeId}</td>
|
|
|
|
| 80 |
) : (!jobs.data?.jobs || jobs.data.jobs.length === 0) ? (
|
| 81 |
<tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">No jobs found.</td></tr>
|
| 82 |
) : (
|
| 83 |
+
jobs.data.jobs.map((job) => (
|
| 84 |
<tr key={job.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 85 |
<td className="px-4 py-3 text-[var(--warm-charcoal)] font-mono text-xs">{job.userId?.slice(0, 10)}...</td>
|
| 86 |
<td className="px-4 py-3">{job.examTypeId}</td>
|
apps/web/src/routes/admin.moderation.tsx
CHANGED
|
@@ -58,7 +58,7 @@ function AdminModeration() {
|
|
| 58 |
) : (!questions.data?.questions || questions.data.questions.length === 0) ? (
|
| 59 |
<tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">No questions yet.</td></tr>
|
| 60 |
) : (
|
| 61 |
-
questions.data.questions.map((q
|
| 62 |
<tr key={q.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 63 |
<td className="px-4 py-3 max-w-xs">
|
| 64 |
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
|
|
|
|
| 58 |
) : (!questions.data?.questions || questions.data.questions.length === 0) ? (
|
| 59 |
<tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">No questions yet.</td></tr>
|
| 60 |
) : (
|
| 61 |
+
questions.data.questions.map((q) => (
|
| 62 |
<tr key={q.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 63 |
<td className="px-4 py-3 max-w-xs">
|
| 64 |
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
|
apps/web/src/routes/admin.tsx
CHANGED
|
@@ -60,7 +60,7 @@ function AdminLayout() {
|
|
| 60 |
<span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
|
| 61 |
</Link>
|
| 62 |
</div>
|
| 63 |
-
<
|
| 64 |
<nav className="flex flex-col gap-1">
|
| 65 |
{adminNavItems.map((item) => {
|
| 66 |
const isActive = location.pathname === item.to;
|
|
|
|
| 60 |
<span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
|
| 61 |
</Link>
|
| 62 |
</div>
|
| 63 |
+
<div className="px-2 mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--warm-charcoal)]">Admin Panel</div>
|
| 64 |
<nav className="flex flex-col gap-1">
|
| 65 |
{adminNavItems.map((item) => {
|
| 66 |
const isActive = location.pathname === item.to;
|
apps/web/src/routes/admin.users.tsx
CHANGED
|
@@ -46,8 +46,9 @@ function AdminUsers() {
|
|
| 46 |
<Input
|
| 47 |
placeholder="Search by name or email..."
|
| 48 |
value={search}
|
| 49 |
-
onChange={(e) =>
|
| 50 |
-
|
|
|
|
| 51 |
/>
|
| 52 |
</div>
|
| 53 |
<table className="w-full text-sm">
|
|
|
|
| 46 |
<Input
|
| 47 |
placeholder="Search by name or email..."
|
| 48 |
value={search}
|
| 49 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 50 |
+
aria-label="Cari user"
|
| 51 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 52 |
/>
|
| 53 |
</div>
|
| 54 |
<table className="w-full text-sm">
|
apps/web/src/routes/analytics.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
|
|
| 1 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 2 |
import { authClient } from "@/lib/auth-client";
|
| 3 |
import { useAnalytics } from "@/hooks/use-analytics";
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
import { OverviewCards } from "@/components/analytics/OverviewCards";
|
| 6 |
-
import { ScoreTrendChart } from "@/components/analytics/ScoreTrendChart";
|
| 7 |
-
import { BreakdownCharts } from "@/components/analytics/BreakdownCharts";
|
| 8 |
import { WeaknessPanel } from "@/components/analytics/WeaknessPanel";
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
export const Route = createFileRoute("/analytics")({
|
| 12 |
component: AnalyticsComponent,
|
|
@@ -19,6 +19,15 @@ export const Route = createFileRoute("/analytics")({
|
|
| 19 |
},
|
| 20 |
});
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
function AnalyticsComponent() {
|
| 23 |
const {
|
| 24 |
overview,
|
|
@@ -40,10 +49,7 @@ function AnalyticsComponent() {
|
|
| 40 |
<div key={i} className="h-28 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 41 |
))}
|
| 42 |
</div>
|
| 43 |
-
<
|
| 44 |
-
<div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 45 |
-
<div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 46 |
-
</div>
|
| 47 |
</div>
|
| 48 |
);
|
| 49 |
}
|
|
@@ -77,31 +83,25 @@ function AnalyticsComponent() {
|
|
| 77 |
<OverviewCards data={overview.data} />
|
| 78 |
</div>
|
| 79 |
|
| 80 |
-
{/*
|
| 81 |
-
<
|
| 82 |
-
<
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
{/* Breakdown Charts */}
|
| 86 |
-
<div className="mb-8">
|
| 87 |
-
<BreakdownCharts
|
| 88 |
byExamType={byExamType.data}
|
| 89 |
bySectionType={bySectionType.data}
|
| 90 |
byFormat={byFormat.data}
|
|
|
|
|
|
|
|
|
|
| 91 |
/>
|
| 92 |
-
</
|
| 93 |
|
| 94 |
-
{/* Weaknesses
|
| 95 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 96 |
<WeaknessPanel
|
| 97 |
weaknesses={weaknesses.data?.weaknesses}
|
| 98 |
recommendations={weaknesses.data?.recommendations}
|
| 99 |
/>
|
| 100 |
-
<TimeAnalyticsPanel
|
| 101 |
-
sectionTime={timeAnalytics.data?.sectionTime}
|
| 102 |
-
formatTimeData={timeAnalytics.data?.formatTime}
|
| 103 |
-
timeTrend={timeAnalytics.data?.timeTrend}
|
| 104 |
-
/>
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
);
|
|
|
|
| 1 |
+
import { Suspense, lazy } from "react";
|
| 2 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
| 4 |
import { useAnalytics } from "@/hooks/use-analytics";
|
| 5 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 6 |
import { OverviewCards } from "@/components/analytics/OverviewCards";
|
|
|
|
|
|
|
| 7 |
import { WeaknessPanel } from "@/components/analytics/WeaknessPanel";
|
| 8 |
+
|
| 9 |
+
const ChartsBundle = lazy(() => import("@/components/analytics/ChartsBundle"));
|
| 10 |
|
| 11 |
export const Route = createFileRoute("/analytics")({
|
| 12 |
component: AnalyticsComponent,
|
|
|
|
| 19 |
},
|
| 20 |
});
|
| 21 |
|
| 22 |
+
function ChartSkeleton() {
|
| 23 |
+
return (
|
| 24 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 25 |
+
<div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 26 |
+
<div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
function AnalyticsComponent() {
|
| 32 |
const {
|
| 33 |
overview,
|
|
|
|
| 49 |
<div key={i} className="h-28 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 50 |
))}
|
| 51 |
</div>
|
| 52 |
+
<ChartSkeleton />
|
|
|
|
|
|
|
|
|
|
| 53 |
</div>
|
| 54 |
);
|
| 55 |
}
|
|
|
|
| 83 |
<OverviewCards data={overview.data} />
|
| 84 |
</div>
|
| 85 |
|
| 86 |
+
{/* Charts β lazy loaded */}
|
| 87 |
+
<Suspense fallback={<ChartSkeleton />}>
|
| 88 |
+
<ChartsBundle
|
| 89 |
+
trend={trend.data}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
byExamType={byExamType.data}
|
| 91 |
bySectionType={bySectionType.data}
|
| 92 |
byFormat={byFormat.data}
|
| 93 |
+
sectionTime={timeAnalytics.data?.sectionTime}
|
| 94 |
+
formatTimeData={timeAnalytics.data?.formatTime}
|
| 95 |
+
timeTrend={timeAnalytics.data?.timeTrend}
|
| 96 |
/>
|
| 97 |
+
</Suspense>
|
| 98 |
|
| 99 |
+
{/* Weaknesses */}
|
| 100 |
+
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 101 |
<WeaknessPanel
|
| 102 |
weaknesses={weaknesses.data?.weaknesses}
|
| 103 |
recommendations={weaknesses.data?.recommendations}
|
| 104 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
);
|
apps/web/src/routes/attempt.$id.tsx
CHANGED
|
@@ -7,12 +7,29 @@ import { Button } from "@labas/ui/components/button";
|
|
| 7 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 8 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 9 |
import { formatTime } from "@/lib/time";
|
|
|
|
| 10 |
import { QuestionReviewCard } from "@/components/attempt/QuestionReviewCard";
|
| 11 |
import { ReviewFilterBar } from "@/components/attempt/ReviewFilterBar";
|
| 12 |
import { SkillBreakdown } from "@/components/attempt/SkillBreakdown";
|
| 13 |
|
| 14 |
type FilterStatus = "all" | "wrong" | "correct" | "marked";
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
export const Route = createFileRoute("/attempt/$id")({
|
| 17 |
component: AttemptResultComponent,
|
| 18 |
beforeLoad: async () => {
|
|
@@ -60,24 +77,24 @@ function AttemptResultComponent() {
|
|
| 60 |
// ββ Flatten all questions with section context ββ
|
| 61 |
const allQuestions = useMemo(() => {
|
| 62 |
if (!attempt?.sections) return [];
|
| 63 |
-
return attempt.sections.flatMap((sec
|
| 64 |
-
sec.questions.map((q:
|
| 65 |
q,
|
| 66 |
secIdx,
|
| 67 |
qIdx,
|
| 68 |
-
ans: sec.answers.find((a:
|
| 69 |
})),
|
| 70 |
);
|
| 71 |
}, [attempt]);
|
| 72 |
|
| 73 |
// ββ Filtered questions ββ
|
| 74 |
const partialCount = useMemo(
|
| 75 |
-
() => allQuestions.filter(({ ans }
|
| 76 |
[allQuestions],
|
| 77 |
);
|
| 78 |
|
| 79 |
const filteredQuestions = useMemo(() => {
|
| 80 |
-
return allQuestions.filter(({ q, ans }
|
| 81 |
if (filterStatus === "wrong" && ans?.isCorrect !== false) return false;
|
| 82 |
if (filterStatus === "correct" && ans?.isCorrect !== true) return false;
|
| 83 |
if (filterStatus === "marked") {
|
|
@@ -97,7 +114,7 @@ function AttemptResultComponent() {
|
|
| 97 |
const next = !allExpanded;
|
| 98 |
setAllExpanded(next);
|
| 99 |
if (next) {
|
| 100 |
-
setExpandedIds(new Set(filteredQuestions.map(({ q }
|
| 101 |
} else {
|
| 102 |
setExpandedIds(new Set());
|
| 103 |
}
|
|
@@ -210,6 +227,7 @@ function AttemptResultComponent() {
|
|
| 210 |
<button
|
| 211 |
key={star}
|
| 212 |
onClick={() => ratePackageMutation.mutate({ packageId: attempt.packageId!, score: star })}
|
|
|
|
| 213 |
className="transition-transform hover:scale-110"
|
| 214 |
>
|
| 215 |
<MaterialIcon
|
|
@@ -235,7 +253,7 @@ function AttemptResultComponent() {
|
|
| 235 |
<div className="mb-8">
|
| 236 |
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
|
| 237 |
<div className="space-y-3">
|
| 238 |
-
{attempt.sections.map((sec
|
| 239 |
const secPct = sec.maxScore && sec.maxScore > 0
|
| 240 |
? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
|
| 241 |
: 0;
|
|
@@ -273,9 +291,9 @@ function AttemptResultComponent() {
|
|
| 273 |
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">Klik topik untuk filter soal</span>
|
| 274 |
</h2>
|
| 275 |
<SkillBreakdown
|
| 276 |
-
questions={allQuestions.map(({ q, ans }
|
| 277 |
-
skillTags: q.skillTags,
|
| 278 |
-
isCorrect: ans?.isCorrect,
|
| 279 |
}))}
|
| 280 |
onSkillClick={toggleSkill}
|
| 281 |
activeSkills={filterSkills}
|
|
@@ -298,7 +316,7 @@ function AttemptResultComponent() {
|
|
| 298 |
setFilterStatus={setFilterStatus}
|
| 299 |
filterSkills={filterSkills}
|
| 300 |
setFilterSkills={setFilterSkills}
|
| 301 |
-
questions={allQuestions.map(({ q, ans }
|
| 302 |
skillTags: q.skillTags ?? [],
|
| 303 |
isCorrect: ans?.isCorrect,
|
| 304 |
}))}
|
|
@@ -308,7 +326,7 @@ function AttemptResultComponent() {
|
|
| 308 |
/>
|
| 309 |
|
| 310 |
<div className="space-y-3">
|
| 311 |
-
{filteredQuestions.map(({ q, secIdx, qIdx, ans }
|
| 312 |
<QuestionReviewCard
|
| 313 |
key={q.id}
|
| 314 |
q={q}
|
|
|
|
| 7 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 8 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 9 |
import { formatTime } from "@/lib/time";
|
| 10 |
+
import type { Question } from "@/lib/types";
|
| 11 |
import { QuestionReviewCard } from "@/components/attempt/QuestionReviewCard";
|
| 12 |
import { ReviewFilterBar } from "@/components/attempt/ReviewFilterBar";
|
| 13 |
import { SkillBreakdown } from "@/components/attempt/SkillBreakdown";
|
| 14 |
|
| 15 |
type FilterStatus = "all" | "wrong" | "correct" | "marked";
|
| 16 |
|
| 17 |
+
interface AnswerItem {
|
| 18 |
+
questionId: string;
|
| 19 |
+
userAnswer?: string;
|
| 20 |
+
answer?: string;
|
| 21 |
+
isCorrect?: boolean | null;
|
| 22 |
+
partialScore?: number | null;
|
| 23 |
+
timeSpentSec?: number;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface QuestionAnswer {
|
| 27 |
+
q: Question;
|
| 28 |
+
ans: AnswerItem | null;
|
| 29 |
+
secIdx: number;
|
| 30 |
+
qIdx: number;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
export const Route = createFileRoute("/attempt/$id")({
|
| 34 |
component: AttemptResultComponent,
|
| 35 |
beforeLoad: async () => {
|
|
|
|
| 77 |
// ββ Flatten all questions with section context ββ
|
| 78 |
const allQuestions = useMemo(() => {
|
| 79 |
if (!attempt?.sections) return [];
|
| 80 |
+
return attempt.sections.flatMap((sec, secIdx: number) =>
|
| 81 |
+
sec.questions.map((q: Question, qIdx: number) => ({
|
| 82 |
q,
|
| 83 |
secIdx,
|
| 84 |
qIdx,
|
| 85 |
+
ans: sec.answers.find((a: AnswerItem) => a.questionId === q.id) ?? null,
|
| 86 |
})),
|
| 87 |
);
|
| 88 |
}, [attempt]);
|
| 89 |
|
| 90 |
// ββ Filtered questions ββ
|
| 91 |
const partialCount = useMemo(
|
| 92 |
+
() => allQuestions.filter(({ ans }) => ans?.partialScore != null && ans?.partialScore < 100).length,
|
| 93 |
[allQuestions],
|
| 94 |
);
|
| 95 |
|
| 96 |
const filteredQuestions = useMemo(() => {
|
| 97 |
+
return allQuestions.filter(({ q, ans }) => {
|
| 98 |
if (filterStatus === "wrong" && ans?.isCorrect !== false) return false;
|
| 99 |
if (filterStatus === "correct" && ans?.isCorrect !== true) return false;
|
| 100 |
if (filterStatus === "marked") {
|
|
|
|
| 114 |
const next = !allExpanded;
|
| 115 |
setAllExpanded(next);
|
| 116 |
if (next) {
|
| 117 |
+
setExpandedIds(new Set(filteredQuestions.map(({ q }) => q.id)));
|
| 118 |
} else {
|
| 119 |
setExpandedIds(new Set());
|
| 120 |
}
|
|
|
|
| 227 |
<button
|
| 228 |
key={star}
|
| 229 |
onClick={() => ratePackageMutation.mutate({ packageId: attempt.packageId!, score: star })}
|
| 230 |
+
aria-label={`Nilai ${star} bintang`}
|
| 231 |
className="transition-transform hover:scale-110"
|
| 232 |
>
|
| 233 |
<MaterialIcon
|
|
|
|
| 253 |
<div className="mb-8">
|
| 254 |
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
|
| 255 |
<div className="space-y-3">
|
| 256 |
+
{attempt.sections.map((sec) => {
|
| 257 |
const secPct = sec.maxScore && sec.maxScore > 0
|
| 258 |
? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
|
| 259 |
: 0;
|
|
|
|
| 291 |
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">Klik topik untuk filter soal</span>
|
| 292 |
</h2>
|
| 293 |
<SkillBreakdown
|
| 294 |
+
questions={allQuestions.map(({ q, ans }) => ({
|
| 295 |
+
skillTags: q.skillTags ?? [],
|
| 296 |
+
isCorrect: ans?.isCorrect ?? null,
|
| 297 |
}))}
|
| 298 |
onSkillClick={toggleSkill}
|
| 299 |
activeSkills={filterSkills}
|
|
|
|
| 316 |
setFilterStatus={setFilterStatus}
|
| 317 |
filterSkills={filterSkills}
|
| 318 |
setFilterSkills={setFilterSkills}
|
| 319 |
+
questions={allQuestions.map(({ q, ans }) => ({
|
| 320 |
skillTags: q.skillTags ?? [],
|
| 321 |
isCorrect: ans?.isCorrect,
|
| 322 |
}))}
|
|
|
|
| 326 |
/>
|
| 327 |
|
| 328 |
<div className="space-y-3">
|
| 329 |
+
{filteredQuestions.map(({ q, secIdx, qIdx, ans }) => (
|
| 330 |
<QuestionReviewCard
|
| 331 |
key={q.id}
|
| 332 |
q={q}
|