11 Commits

Author SHA1 Message Date
8f6a68b9f1 Merge pull request 'amalia/24-apr-26' (#13) from amalia/24-apr-26 into main
Reviewed-on: #13
2026-04-24 17:39:46 +08:00
63c0a6acff feat: image upload & preview untuk bug reports via MinIO
- Upload hingga 3 gambar per bug report (FileInput multi-select)
- Backend: POST /api/upload/image → MinIO, GET /api/bugs/images → presigned URL redirect
- Auto-create bucket jika belum ada saat server start
- Preview gambar fullscreen saat thumbnail diklik
- Diterapkan di /bug-reports dan /apps/$appId/errors
- Migrasi storage dari Seafile ke MinIO (minio SDK v8)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:36:32 +08:00
a0ca6be8e1 docs: split CLAUDE.md into focused reference files
Move architecture, testing, and dev tools detail into docs/ and
reference them via @path pointers to keep CLAUDE.md slim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:46:18 +08:00
eb77aca715 Merge pull request 'upd: stack trace hide/show toggle and date format DD/MM/YYYY 24h' (#12) from amalia/22-apr-26 into main
Reviewed-on: #12
2026-04-22 17:32:17 +08:00
041d891a8d upd: stack trace hide/show toggle and date format DD/MM/YYYY 24h
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:25:50 +08:00
de5ec1af93 Merge pull request 'upd: swagger docs, api key auth, bug fixes' (#11) from amalia/21-apr-26 into main
Reviewed-on: #11
2026-04-21 17:33:17 +08:00
d09a702d64 upd: swagger docs, api key auth, bug fixes
- tambah Elysia Swagger di /docs dengan deskripsi lengkap semua endpoint
- tambah API key auth (X-API-Key) untuk klien eksternal di POST /api/bugs
- tambah normalisasi BugSource: SYSTEM/USER untuk eksternal, QC/SYSTEM/USER untuk dashboard
- perbaiki source schema jadi optional string agar tidak reject nilai unknown dari klien lama
- hapus field status dari form create bug (selalu OPEN)
- perbaiki typo desa_plus → appId di apps.$appId.errors.tsx
- tambah toggle hide/show stack trace di bug-reports.tsx dan apps.$appId.errors.tsx
- perbaiki grafik desa (width(-1)/height(-1)) dengan minWidth: 0 pada grid item
- perbaiki error &[data-active] inline style di DashboardLayout → pindah ke CSS class
- update CLAUDE.md dengan arsitektur lengkap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:30:07 +08:00
cd295abf2c Merge pull request 'amalia/16-apr-26' (#10) from amalia/16-apr-26 into main
Reviewed-on: #10
2026-04-16 14:10:26 +08:00
e6dfac1ffe upd: fix graphic data 2026-04-16 11:47:19 +08:00
6ce7c93106 upd: grafik 2026-04-16 10:19:51 +08:00
f446aec734 upd: role akses 2026-04-16 09:52:17 +08:00
22 changed files with 1279 additions and 364 deletions

View File

@@ -16,6 +16,9 @@ GOOGLE_CLIENT_SECRET=
# Role # Role
SUPER_ADMIN_EMAIL=admin@example.com SUPER_ADMIN_EMAIL=admin@example.com
# API Key for external clients (e.g. mobile apps)
API_KEY=your-secret-api-key-here
# Telegram Notification (optional) # Telegram Notification (optional)
TELEGRAM_NOTIFY_TOKEN= TELEGRAM_NOTIFY_TOKEN=
TELEGRAM_NOTIFY_CHAT_ID= TELEGRAM_NOTIFY_CHAT_ID=

102
CLAUDE.md
View File

@@ -1,82 +1,52 @@
Default to using Bun instead of Node.js. # CLAUDE.md
- Use `bun <file>` instead of `node <file>` or `ts-node <file>` This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Use `bun test` instead of `jest` or `vitest`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## Server ## Runtime
Elysia.js as the HTTP framework, running on Bun. API routes are in `src/app.ts` (exported as `createApp()`), frontend serving and dev tools are in `src/index.tsx`. Default to Bun instead of Node.js everywhere:
- `src/app.ts` — Elysia app factory with all API routes (auth, hello, health, Google OAuth). Testable via `app.handle(request)`. - `bun <file>` not `node` / `ts-node`
- `src/index.tsx` — Server entry. Adds Vite middleware (dev) or static file serving (prod), click-to-source editor integration, and `.listen()`. - `bun test` not `jest` / `vitest`
- `src/serve.ts` — Dev entry (`bun --watch src/serve.ts`). Dynamic import workaround for Bun EADDRINUSE race. - `bun install` not `npm install` / `yarn` / `pnpm`
- `bun run <script>` not `npm run`
- `bunx <pkg>` not `npx`
- Bun auto-loads `.env` — never use dotenv.
## Database ## Common Commands
PostgreSQL via Prisma v6. Client generated to `./generated/prisma` (gitignored). ```bash
bun run dev # dev server with hot reload (bun --watch src/serve.ts)
bun run build # Vite production build
bun run start # production server (NODE_ENV=production)
bun run typecheck # tsc --noEmit
bun run lint # biome check src/
bun run lint:fix # biome check --write src/
- Schema: `prisma/schema.prisma` — User (id, name, email, password, timestamps) + Session (id, token, userId, expiresAt) # Database
- Client singleton: `src/lib/db.ts` — import `{ prisma }` from here bun run db:migrate # prisma migrate dev
- Seed: `prisma/seed.ts` — demo users with `Bun.password.hash` bcrypt bun run db:seed # seed demo data
- Commands: `bun run db:migrate`, `bun run db:seed`, `bun run db:generate` bun run db:generate # regenerate prisma client
bun run db:studio # Prisma Studio GUI
bun run db:push # push schema without migration
## Auth # Tests
bun run test # all tests
bun run test:unit # tests/unit/
bun run test:integration # tests/integration/ — no server needed
bun run test:e2e # tests/e2e/ — requires Lightpanda Docker
```
Session-based auth with HttpOnly cookies stored in DB. Run a single test file: `bun test tests/integration/auth.test.ts`
- Login: `POST /api/auth/login` — finds user by email, verifies password with `Bun.password.verify`, creates Session record ## Architecture
- Google OAuth: `GET /api/auth/google` → Google → `GET /api/auth/callback/google` — upserts user, creates session
- Session: `GET /api/auth/session` — looks up session by cookie token, returns user or 401, auto-deletes expired
- Logout: `POST /api/auth/logout` — deletes session from DB, clears cookie
## Frontend See @docs/ARCHITECTURE.md
React 19 + Vite 8 (middleware mode in dev). File-based routing with TanStack Router.
- Entry: `src/frontend.tsx` — renders App, removes splash screen, DevInspector in dev
- App: `src/frontend/App.tsx` — MantineProvider (dark, forced), QueryClientProvider, RouterProvider
- Routes: `src/frontend/routes/``__root.tsx`, `index.tsx`, `login.tsx`, `dashboard.tsx`
- Auth hooks: `src/frontend/hooks/useAuth.ts``useSession()`, `useLogin()`, `useLogout()`
- UI: Mantine v8 (dark theme `#242424`), react-icons
- Splash: `index.html` has inline dark CSS + spinner, removed on React mount
## Dev Tools
- Click-to-source: `Ctrl+Shift+Cmd+C` toggles inspector. Custom Vite plugin (`inspectorPlugin` in `src/vite.ts`) injects `data-inspector-*` attributes. Reads original file from disk for accurate line numbers.
- HMR: Vite 8 with `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` fixes double React Refresh injection.
- Editor: `REACT_EDITOR` env var. `zed` and `subl` use `file:line:col`, others use `--goto file:line:col`.
## Playwright MCP
Playwright MCP server enables AI-assisted browser automation for testing and debugging.
- MCP config: `.qwen/settings.json` — Qwen Code auto-loads on session start
- Playwright config: `playwright.config.ts` — E2E test configuration
- Run manually: `bun run mcp:playwright` — starts headless browser MCP server
- Install browsers: `bunx playwright install` — downloads Chromium and other browsers
## Testing ## Testing
Tests use `bun:test`. Three levels: See @docs/TESTING.md
```bash ## Dev Tools
bun run test # All tests
bun run test:unit # tests/unit/ — env, db connection, bcrypt
bun run test:integration # tests/integration/ — API endpoints via app.handle()
bun run test:e2e # tests/e2e/ — browser tests via Lightpanda CDP
```
- `tests/helpers.ts``createTestApp()`, `seedTestUser()`, `createTestSession()`, `cleanupTestData()` See @docs/DEV_TOOLS.md
- Integration tests use `createApp().handle(new Request(...))` — no server needed
- E2E tests use Lightpanda browser (Docker, `ws://127.0.0.1:9222`). App URLs use `host.docker.internal` from container. Lightpanda executes JS but POST fetch returns 407 — use integration tests for mutations.
## APIs
- `Bun.password.hash()` / `Bun.password.verify()` for bcrypt
- `Bun.file()` for static file serving in production
- `Bun.which()` / `Bun.spawn()` for editor integration
- `crypto.randomUUID()` for session tokens

135
bun.lock
View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "bun-react-template", "name": "bun-react-template",
@@ -8,13 +7,16 @@
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9", "@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6", "@prisma/client": "6",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
"elysia": "^1.4.28", "elysia": "^1.4.28",
"minio": "^8.0.7",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
@@ -26,11 +28,14 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",
"@playwright/mcp": "^0.0.70",
"@playwright/test": "^1.59.1",
"@tanstack/router-vite-plugin": "^1.166.27", "@tanstack/router-vite-plugin": "^1.166.27",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"playwright": "^1.59.1",
"prisma": "6", "prisma": "6",
"puppeteer-core": "^24.40.0", "puppeteer-core": "^24.40.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
@@ -105,6 +110,8 @@
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="], "@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
"@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-LcbLHa0zE6FJKWPWKsIC/f+62wbDv3aXydqcNPVPyqNcaUgwvCajIi+5kHEU6GO3oXUCpzKaMsb3gsjt8sLzFQ=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
@@ -193,10 +200,20 @@
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="], "@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
"@mantine/notifications": ["@mantine/notifications@8.3.18", "", { "dependencies": { "@mantine/store": "8.3.18", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw=="],
"@mantine/store": ["@mantine/store@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@playwright/mcp": ["@playwright/mcp@0.0.70", "", { "dependencies": { "playwright": "1.60.0-alpha-1774999321000", "playwright-core": "1.60.0-alpha-1774999321000" }, "bin": { "playwright-mcp": "cli.js" } }, "sha512-Kl0a6l9VL8rvT1oBou3hS5yArjwWV9UlwAkq+0skfK1YVg8XfmmNaAmwZhMeNx/ZhGiWXfCllo6rD/jvZz+WuA=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
"@prisma/client": ["@prisma/client@6.19.2", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg=="], "@prisma/client": ["@prisma/client@6.19.2", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg=="],
"@prisma/config": ["@prisma/config@6.19.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ=="], "@prisma/config": ["@prisma/config@6.19.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ=="],
@@ -247,6 +264,12 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="],
"@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -315,6 +338,8 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -331,6 +356,8 @@
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
@@ -353,11 +380,15 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
@@ -425,6 +456,8 @@
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
@@ -443,6 +476,8 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
@@ -487,6 +522,10 @@
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
"fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -495,7 +534,9 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -515,6 +556,8 @@
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -523,10 +566,14 @@
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -571,10 +618,20 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minio": ["minio@8.0.7", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^5.3.4", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -591,6 +648,8 @@
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -601,7 +660,9 @@
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
@@ -613,6 +674,10 @@
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.60.0-alpha-1774999321000", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-ams3Zo4VXxeOg5ZTTh16GkE8g48Bmxo/9pg9gXl9SVKlVohCU7Jaog7XntY8yFuzENA6dJc1Fz7Z/NNTm9nGEw=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
@@ -633,6 +698,8 @@
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
@@ -643,6 +710,8 @@
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@@ -665,6 +734,10 @@
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
@@ -683,6 +756,10 @@
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -701,12 +778,24 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
"streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="], "sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
@@ -723,6 +812,8 @@
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
@@ -779,6 +870,10 @@
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@@ -789,6 +884,8 @@
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -799,30 +896,60 @@
"@kitajs/ts-html-plugin/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "@kitajs/ts-html-plugin/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"@playwright/mcp/playwright": ["playwright@1.60.0-alpha-1774999321000", "", { "dependencies": { "playwright-core": "1.60.0-alpha-1774999321000" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Bd5DkzYKG+2g1jLO6NeTXmGLbBYSFffJIOsR4l4hUBkJvzvGGdLZ7jZb2tOtb0WIoWXQKdQj3Ap6WthV4DBS8w=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
"@tanstack/router-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"playwright/playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"@kitajs/ts-html-plugin/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "@kitajs/ts-html-plugin/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"@kitajs/ts-html-plugin/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "@kitajs/ts-html-plugin/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"@kitajs/ts-html-plugin/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "@kitajs/ts-html-plugin/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="],
"c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"@kitajs/ts-html-plugin/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@kitajs/ts-html-plugin/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],

66
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,66 @@
# Architecture
## Server
Elysia.js on Bun. All API routes are in `src/app.ts` as `createApp()` — testable via `app.handle(request)` without starting a server. `src/index.tsx` adds Vite middleware (dev) or static serving (prod) and calls `.listen()`. `src/serve.ts` is the dev entry point (dynamic import workaround for Bun EADDRINUSE race).
## Database
PostgreSQL via Prisma v6. Client generated to `./generated/prisma` (gitignored — run `bun run db:generate` after checkout or schema changes).
**Schema models:** `User`, `Session`, `App`, `Log`, `Bug`, `BugImage`, `BugLog`
**Enums:** `Role` (ADMIN, DEVELOPER), `BugStatus` (OPEN, ON_HOLD, IN_PROGRESS, RESOLVED, RELEASED, CLOSED), `BugSource` (QC, SYSTEM, USER), `LogType` (CREATE, UPDATE, DELETE, LOGIN, LOGOUT)
Import the singleton: `import { prisma } from './lib/db'`
## Auth & Roles
Session-based auth with HttpOnly cookies stored in the DB (24h expiry). Two roles: `DEVELOPER` (super admin) and `ADMIN`. Users listed in `SUPER_ADMIN_EMAIL` env var are auto-promoted to DEVELOPER on login.
Endpoints: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/session`
Auth state on the frontend is managed via `useSession()` / `useLogin()` / `useLogout()` in `src/frontend/hooks/useAuth.ts` (TanStack Query).
## Frontend
React 19 + Vite 8 (middleware mode in dev). TanStack Router with file-based routing in `src/frontend/routes/`. All routes are wrapped in `DashboardLayout` from `src/frontend/components/DashboardLayout.tsx`.
**Route structure:**
- `/` → redirect
- `/login` → login page
- `/dashboard` → stats overview
- `/apps` → app list
- `/apps/$appId` → per-app layout with nested routes: `index`, `errors`, `logs`, `users`, `villages`, `orders`, `products`, `payments`
- `/users` → operator management
- `/logs` → system activity log
- `/bug-reports` → cross-app bug reports
- `/profile` → user profile
**App configs** are defined in `src/frontend/config/appMenus.ts` — each app has an ID and a menu list. Currently active: `desa-plus`. Add new app entries here to register them.
**routeTree.gen.ts** is auto-generated by the TanStack Router Vite plugin — never edit it manually.
**UI:** Mantine v8, dark theme forced (`#242424`). Charts use `@mantine/charts` (recharts under the hood). Icons from `react-icons/tb`.
## API Structure
All API routes live in `src/app.ts`. Key groups:
- `/api/auth/*` — authentication
- `/api/dashboard/*` — stats and recent errors
- `/api/apps`, `/api/apps/:appId` — app listing and detail
- `/api/bugs`, `/api/bugs/:id/status`, `/api/bugs/:id/feedback` — bug report CRUD
- `/api/operators`, `/api/operators/:id` — user management
- `/api/logs` — system activity log
- `/api/system/status` — health check with DB connectivity
## Logging
`createSystemLog(userId, type, message)` from `src/lib/logger.ts` writes to the `Log` model. Call it for any significant user action (login/logout/CRUD). Logging errors are swallowed so they never break the main flow.
## Environment Variables
Required: `DATABASE_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
Optional: `PORT` (default 3000), `NODE_ENV`, `REACT_EDITOR`, `SUPER_ADMIN_EMAIL` (comma-separated)
Validated at startup in `src/lib/env.ts` — missing required vars throw immediately.

6
docs/DEV_TOOLS.md Normal file
View File

@@ -0,0 +1,6 @@
# Dev Tools
- **Click-to-source:** `Ctrl+Shift+Cmd+C` toggles inspector. Custom Vite plugin in `src/vite.ts` injects `data-inspector-*` attributes; reads original source from disk for accurate line numbers.
- **HMR:** Vite 8 + `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` in `src/vite.ts` prevents double React Refresh injection.
- **Editor:** Set `REACT_EDITOR` env var. `zed`/`subl` use `file:line:col`; others get `--goto file:line:col`.
- **Playwright MCP:** `bun run mcp:playwright` starts headless browser MCP server (config in `.qwen/settings.json`).

6
docs/TESTING.md Normal file
View File

@@ -0,0 +1,6 @@
# Testing
- **Unit:** env, DB connection, bcrypt — in `tests/unit/`
- **Integration:** `createApp().handle(new Request(...))` — no running server needed, use these for mutations
- **E2E:** Lightpanda browser via CDP (`ws://127.0.0.1:9222`). App URLs use `host.docker.internal` from inside Docker. Lightpanda executes JS but POST fetch returns 407 — use integration tests for anything that writes data.
- **Helpers:** `tests/helpers.ts``createTestApp()`, `seedTestUser()`, `createTestSession()`, `cleanupTestData()`

View File

@@ -25,6 +25,7 @@
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.9", "@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0", "@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18", "@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.18",
@@ -33,6 +34,7 @@
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
"elysia": "^1.4.28", "elysia": "^1.4.28",
"minio": "^8.0.7",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",

View File

@@ -1,12 +1,57 @@
import { cors } from '@elysiajs/cors' import { cors } from '@elysiajs/cors'
import { html } from '@elysiajs/html' import { html } from '@elysiajs/html'
import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger'
import { Elysia, t } from 'elysia'
import { BugSource } from '../generated/prisma'
import { prisma } from './lib/db' import { prisma } from './lib/db'
import { env } from './lib/env' import { env } from './lib/env'
import { createSystemLog } from './lib/logger' import { createSystemLog } from './lib/logger'
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
interface AuthResult {
actingUserId: string
reporterUserId: string | null // null jika via API key (tidak ada user spesifik)
isApiKey: boolean // true = dari klien eksternal (mobile app)
}
// Validates session cookie OR X-API-Key header. Returns null if neither is valid.
async function checkAuth(request: Request): Promise<AuthResult | null> {
const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1]
if (token) {
const session = await prisma.session.findUnique({ where: { token } })
if (session && session.expiresAt > new Date()) {
return { actingUserId: session.userId, reporterUserId: session.userId, isApiKey: false }
}
}
const apiKey = request.headers.get('x-api-key')
if (apiKey && apiKey === env.API_KEY) {
const developer = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
if (!developer) return null
return { actingUserId: developer.id, reporterUserId: null, isApiKey: true }
}
return null
}
export function createApp() { export function createApp() {
return new Elysia() return new Elysia()
.use(swagger({
path: '/docs',
documentation: {
info: { title: 'Monitoring App API', version: '0.1.0' },
tags: [
{ name: 'Auth', description: 'Autentikasi dan manajemen sesi' },
{ name: 'Dashboard', description: 'Statistik dan ringkasan monitoring' },
{ name: 'Apps', description: 'Manajemen aplikasi yang dimonitor' },
{ name: 'Bugs', description: 'Manajemen laporan bug' },
{ name: 'Logs', description: 'Log aktivitas sistem' },
{ name: 'Operators', description: 'Manajemen operator / pengguna sistem' },
{ name: 'System', description: 'Status dan kesehatan sistem' },
],
},
}))
.use(cors()) .use(cors())
.use(html()) .use(html())
@@ -25,12 +70,18 @@ export function createApp() {
}) })
}) })
// API routes // ─── Health ───────────────────────────────────────
.get('/health', () => ({ status: 'ok' })) .get('/health', () => ({ status: 'ok' }), {
detail: {
summary: 'Health Check',
description: 'Memeriksa apakah server sedang berjalan.',
tags: ['System'],
},
})
// ─── Auth API ────────────────────────────────────── // ─── Auth API ──────────────────────────────────────
.post('/api/auth/login', async ({ request, set }) => { .post('/api/auth/login', async ({ body, set }) => {
const { email, password } = (await request.json()) as { email: string; password: string } const { email, password } = body
let user = await prisma.user.findUnique({ where: { email } }) let user = await prisma.user.findUnique({ where: { email } })
if (!user || !(await Bun.password.verify(password, user.password))) { if (!user || !(await Bun.password.verify(password, user.password))) {
set.status = 401 set.status = 401
@@ -46,6 +97,16 @@ export function createApp() {
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully') await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
}, {
body: t.Object({
email: t.String({ format: 'email', description: 'Email pengguna' }),
password: t.String({ minLength: 1, description: 'Password pengguna' }),
}),
detail: {
summary: 'Login',
description: 'Login dengan email dan password. Mengembalikan data user dan set session cookie (HttpOnly, 24 jam). Jika email terdaftar di SUPER_ADMIN_EMAIL, role otomatis di-promote ke DEVELOPER.',
tags: ['Auth'],
},
}) })
.post('/api/auth/logout', async ({ request, set }) => { .post('/api/auth/logout', async ({ request, set }) => {
@@ -60,6 +121,12 @@ export function createApp() {
} }
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0' set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
return { ok: true } return { ok: true }
}, {
detail: {
summary: 'Logout',
description: 'Menghapus sesi aktif dari database dan membersihkan session cookie.',
tags: ['Auth'],
},
}) })
.get('/api/auth/session', async ({ request, set }) => { .get('/api/auth/session', async ({ request, set }) => {
@@ -76,11 +143,15 @@ export function createApp() {
return { user: null } return { user: null }
} }
return { user: session.user } return { user: session.user }
}, {
detail: {
summary: 'Get Current Session',
description: 'Mengembalikan data user dari sesi aktif berdasarkan session cookie. Mengembalikan 401 jika tidak ada sesi atau sudah kadaluarsa.',
tags: ['Auth'],
},
}) })
// ─── Dashboard API ─────────────────────────────────
// ─── Monitoring API ────────────────────────────────
.get('/api/dashboard/stats', async () => { .get('/api/dashboard/stats', async () => {
const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } }) const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } })
const users = await prisma.user.count() const users = await prisma.user.count()
@@ -90,6 +161,12 @@ export function createApp() {
activeUsers: users, activeUsers: users,
trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 } trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 }
} }
}, {
detail: {
summary: 'Dashboard Stats',
description: 'Mengembalikan statistik utama dashboard: total aplikasi, jumlah error baru (status OPEN), total pengguna, dan data tren.',
tags: ['Dashboard'],
},
}) })
.get('/api/dashboard/recent-errors', async () => { .get('/api/dashboard/recent-errors', async () => {
@@ -105,10 +182,17 @@ export function createApp() {
time: b.createdAt.toISOString(), time: b.createdAt.toISOString(),
severity: b.status severity: b.status
})) }))
}, {
detail: {
summary: 'Recent Errors',
description: 'Mengembalikan 5 bug report terbaru (diurutkan dari yang terbaru) untuk ditampilkan di dashboard.',
tags: ['Dashboard'],
},
}) })
// ─── Apps API ──────────────────────────────────────
.get('/api/apps', async ({ query }) => { .get('/api/apps', async ({ query }) => {
const search = (query.search as string) || '' const search = query.search || ''
const where: any = {} const where: any = {}
if (search) { if (search) {
where.name = { contains: search, mode: 'insensitive' } where.name = { contains: search, mode: 'insensitive' }
@@ -131,6 +215,15 @@ export function createApp() {
version: app.version ?? '-', version: app.version ?? '-',
maintenance: app.maintenance, maintenance: app.maintenance,
})) }))
}, {
query: t.Object({
search: t.Optional(t.String({ description: 'Filter berdasarkan nama aplikasi' })),
}),
detail: {
summary: 'List Apps',
description: 'Mengembalikan semua aplikasi yang dimonitor beserta status (active/warning/error), jumlah bug OPEN, versi, dan mode maintenance.',
tags: ['Apps'],
},
}) })
.get('/api/apps/:appId', async ({ params: { appId }, set }) => { .get('/api/apps/:appId', async ({ params: { appId }, set }) => {
@@ -157,14 +250,24 @@ export function createApp() {
maintenance: app.maintenance, maintenance: app.maintenance,
totalBugs: app._count.bugs, totalBugs: app._count.bugs,
} }
}, {
params: t.Object({
appId: t.String({ description: 'ID aplikasi (contoh: desa-plus)' }),
}),
detail: {
summary: 'Get App Detail',
description: 'Mengembalikan detail satu aplikasi berdasarkan ID, termasuk status, versi minimum, mode maintenance, dan total semua bug.',
tags: ['Apps'],
},
}) })
// ─── Logs API ──────────────────────────────────────
.get('/api/logs', async ({ query }) => { .get('/api/logs', async ({ query }) => {
const page = Number(query.page) || 1 const page = Number(query.page) || 1
const limit = Number(query.limit) || 20 const limit = Number(query.limit) || 20
const search = (query.search as string) || '' const search = query.search || ''
const type = query.type as any const type = query.type as any
const userId = query.userId as string const userId = query.userId
const where: any = {} const where: any = {}
if (search) { if (search) {
@@ -196,31 +299,63 @@ export function createApp() {
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
totalItems: total totalItems: total
} }
}, {
query: t.Object({
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })),
type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })),
userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })),
}),
detail: {
summary: 'List Activity Logs',
description: 'Mengembalikan log aktivitas sistem dengan pagination. Mendukung filter berdasarkan tipe log (CREATE, UPDATE, DELETE, LOGIN, LOGOUT) dan pengguna.',
tags: ['Logs'],
},
}) })
.post('/api/logs', async ({ request, set }) => { .post('/api/logs', async ({ body, request }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
let userId: string | undefined let userId: string | undefined
if (token) { if (token) {
const session = await prisma.session.findUnique({ where: { token } }) const session = await prisma.session.findUnique({ where: { token } })
if (session && session.expiresAt > new Date()) { if (session && session.expiresAt > new Date()) userId = session.userId
userId = session.userId
}
} }
const body = (await request.json()) as { type: string, message: string }
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || '' const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || ''
await createSystemLog(actingUserId, body.type as any, body.message) await createSystemLog(actingUserId, body.type as any, body.message)
return { ok: true } return { ok: true }
}, {
body: t.Object({
type: t.String({ description: 'Tipe log: CREATE | UPDATE | DELETE | LOGIN | LOGOUT' }),
message: t.String({ description: 'Pesan log yang akan dicatat' }),
}),
detail: {
summary: 'Create Log',
description: 'Mencatat log aktivitas sistem. Jika ada session cookie yang valid, log dikaitkan ke pengguna yang sedang login. Jika tidak, log dikaitkan ke akun DEVELOPER pertama.',
tags: ['Logs'],
},
}) })
.get('/api/logs/operators', async () => {
return await prisma.user.findMany({
select: { id: true, name: true, image: true },
orderBy: { name: 'asc' }
})
}, {
detail: {
summary: 'List Operators for Log Filter',
description: 'Mengembalikan daftar semua pengguna (id, name, image) sebagai opsi filter pada halaman log aktivitas.',
tags: ['Logs'],
},
})
// ─── Operators API ─────────────────────────────────
.get('/api/operators', async ({ query }) => { .get('/api/operators', async ({ query }) => {
const page = Number(query.page) || 1 const page = Number(query.page) || 1
const limit = Number(query.limit) || 20 const limit = Number(query.limit) || 20
const search = (query.search as string) || '' const search = query.search || ''
const where: any = {} const where: any = {}
if (search) { if (search) {
@@ -246,11 +381,22 @@ export function createApp() {
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
totalItems: total totalItems: total
} }
}, {
query: t.Object({
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
search: t.Optional(t.String({ description: 'Cari berdasarkan nama atau email' })),
}),
detail: {
summary: 'List Operators',
description: 'Mengembalikan daftar operator/pengguna sistem dengan pagination. Mendukung pencarian berdasarkan nama dan email.',
tags: ['Operators'],
},
}) })
.get('/api/operators/stats', async () => { .get('/api/operators/stats', async () => {
const [totalStaff, activeNow, rolesGroup] = await Promise.all([ const [totalStaff, activeNow, rolesGroup] = await Promise.all([
prisma.user.count({where: {active: true}}), prisma.user.count({ where: { active: true } }),
prisma.session.count({ prisma.session.count({
where: { expiresAt: { gte: new Date() } }, where: { expiresAt: { gte: new Date() } },
}), }),
@@ -265,9 +411,15 @@ export function createApp() {
activeNow, activeNow,
rolesCount: rolesGroup.length rolesCount: rolesGroup.length
} }
}, {
detail: {
summary: 'Operator Stats',
description: 'Mengembalikan statistik operator: total staf aktif, jumlah sesi yang sedang aktif saat ini, dan jumlah role yang ada.',
tags: ['Operators'],
},
}) })
.post('/api/operators', async ({ request, set }) => { .post('/api/operators', async ({ body, request, set }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
let userId: string | undefined let userId: string | undefined
@@ -276,8 +428,6 @@ export function createApp() {
if (session && session.expiresAt > new Date()) userId = session.userId if (session && session.expiresAt > new Date()) userId = session.userId
} }
const body = (await request.json()) as { name: string; email: string; password: string; role: string }
const existing = await prisma.user.findUnique({ where: { email: body.email } }) const existing = await prisma.user.findUnique({ where: { email: body.email } })
if (existing) { if (existing) {
set.status = 400 set.status = 400
@@ -299,9 +449,21 @@ export function createApp() {
} }
return { id: user.id, name: user.name, email: user.email, role: user.role } return { id: user.id, name: user.name, email: user.email, role: user.role }
}, {
body: t.Object({
name: t.String({ minLength: 1, description: 'Nama lengkap operator' }),
email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }),
password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }),
role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }),
}),
detail: {
summary: 'Create Operator',
description: 'Membuat akun operator baru. Password di-hash dengan bcrypt sebelum disimpan. Gagal jika email sudah terdaftar.',
tags: ['Operators'],
},
}) })
.patch('/api/operators/:id', async ({ params: { id }, request, set }) => { .patch('/api/operators/:id', async ({ params: { id }, body, request }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
let userId: string | undefined let userId: string | undefined
@@ -310,8 +472,6 @@ export function createApp() {
if (session && session.expiresAt > new Date()) userId = session.userId if (session && session.expiresAt > new Date()) userId = session.userId
} }
const body = (await request.json()) as { name?: string; email?: string; role?: string; active?: boolean }
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { id }, where: { id },
data: { data: {
@@ -327,6 +487,21 @@ export function createApp() {
} }
return { id: user.id, name: user.name, email: user.email, role: user.role, active: user.active } return { id: user.id, name: user.name, email: user.email, role: user.role, active: user.active }
}, {
params: t.Object({
id: t.String({ description: 'ID operator yang akan diupdate' }),
}),
body: t.Object({
name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })),
email: t.Optional(t.String({ format: 'email', description: 'Email baru' })),
role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })),
active: t.Optional(t.Boolean({ description: 'Status aktif operator' })),
}),
detail: {
summary: 'Update Operator',
description: 'Mengupdate data operator secara parsial. Semua field bersifat opsional — hanya field yang dikirim yang akan diupdate.',
tags: ['Operators'],
},
}) })
.delete('/api/operators/:id', async ({ params: { id }, request, set }) => { .delete('/api/operators/:id', async ({ params: { id }, request, set }) => {
@@ -358,19 +533,22 @@ export function createApp() {
} }
return { ok: true } return { ok: true }
}, {
params: t.Object({
id: t.String({ description: 'ID operator yang akan dideactivate' }),
}),
detail: {
summary: 'Deactivate Operator',
description: 'Menonaktifkan akun operator (soft delete: set active=false). Semua sesi aktif operator tersebut ikut dihapus. Tidak bisa menghapus akun sendiri.',
tags: ['Operators'],
},
}) })
.get('/api/logs/operators', async () => { // ─── Bugs API ──────────────────────────────────────
return await prisma.user.findMany({
select: { id: true, name: true, image: true },
orderBy: { name: 'asc' }
})
})
.get('/api/bugs', async ({ query }) => { .get('/api/bugs', async ({ query }) => {
const page = Number(query.page) || 1 const page = Number(query.page) || 1
const limit = Number(query.limit) || 20 const limit = Number(query.limit) || 20
const search = (query.search as string) || '' const search = query.search || ''
const app = query.app as any const app = query.app as any
const status = query.status as any const status = query.status as any
@@ -413,23 +591,28 @@ export function createApp() {
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
totalItems: total, totalItems: total,
} }
}, {
query: t.Object({
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
search: t.Optional(t.String({ description: 'Cari berdasarkan deskripsi, device, OS, atau versi' })),
app: t.Optional(t.String({ description: 'Filter berdasarkan ID aplikasi, atau "all"' })),
status: t.Optional(t.String({ description: 'Filter status: OPEN | ON_HOLD | IN_PROGRESS | RESOLVED | RELEASED | CLOSED | all' })),
}),
detail: {
summary: 'List Bug Reports',
description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi dan status.',
tags: ['Bugs'],
},
}) })
.post('/api/bugs', async ({ request, set }) => { .post('/api/bugs', async ({ body, request, set }) => {
const cookie = request.headers.get('cookie') ?? '' const auth = await checkAuth(request)
const token = cookie.match(/session=([^;]+)/)?.[1] if (!auth) {
let userId: string | undefined set.status = 401
return { error: 'Unauthorized: sertakan session cookie atau header X-API-Key' }
if (token) {
const session = await prisma.session.findUnique({ where: { token } })
if (session && session.expiresAt > new Date()) {
userId = session.userId
}
} }
const { actingUserId, reporterUserId, isApiKey } = auth
const body = (await request.json()) as any
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
const actingUserId = userId || defaultAdmin?.id || ''
const bug = await prisma.bug.create({ const bug = await prisma.bug.create({
data: { data: {
@@ -437,20 +620,18 @@ export function createApp() {
affectedVersion: body.affectedVersion, affectedVersion: body.affectedVersion,
device: body.device, device: body.device,
os: body.os, os: body.os,
status: body.status || 'OPEN', status: 'OPEN',
source: body.source || 'USER', source: body.source as BugSource,
description: body.description, description: body.description,
stackTrace: body.stackTrace, stackTrace: body.stackTrace,
userId: userId, userId: reporterUserId,
images: body.imageUrl ? { images: body.imageUrls?.length ? {
create: { createMany: { data: body.imageUrls.map(imageUrl => ({ imageUrl })) }
imageUrl: body.imageUrl
}
} : undefined, } : undefined,
logs: { logs: {
create: { create: {
userId: actingUserId, userId: actingUserId,
status: body.status || 'OPEN', status: 'OPEN',
description: 'Bug reported initially.', description: 'Bug reported initially.',
}, },
}, },
@@ -458,9 +639,27 @@ export function createApp() {
}) })
return bug return bug
}, {
body: t.Object({
app: t.Optional(t.String({ description: 'ID aplikasi terkait (contoh: desa-plus)' })),
affectedVersion: t.String({ description: 'Versi aplikasi yang terdampak bug' }),
device: t.String({ description: 'Tipe/model perangkat pengguna' }),
os: t.String({ description: 'Sistem operasi perangkat (contoh: Android 13, iOS 17)' }),
description: t.String({ minLength: 1, description: 'Deskripsi bug yang ditemukan' }),
stackTrace: t.Optional(t.String({ description: 'Stack trace error (opsional)' })),
source: t.Optional(t.String({
description: 'Sumber laporan: QC | SYSTEM | USER',
})),
imageUrls: t.Optional(t.Array(t.String(), { description: 'URL gambar screenshot bug, maks 3 (opsional)' })),
}),
detail: {
summary: 'Create Bug Report',
description: 'Membuat laporan bug baru dengan status awal OPEN. Bisa diakses via session cookie (frontend) atau X-API-Key (klien eksternal seperti Desa+). Jika via API key, userId pelapor null dan source default USER.',
tags: ['Bugs'],
},
}) })
.patch('/api/bugs/:id/feedback', async ({ params: { id }, request }) => { .patch('/api/bugs/:id/feedback', async ({ params: { id }, body, request }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
let userId: string | undefined let userId: string | undefined
@@ -472,15 +671,12 @@ export function createApp() {
} }
} }
const body = (await request.json()) as { feedBack: string }
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
const actingUserId = userId || defaultAdmin?.id || undefined const actingUserId = userId || defaultAdmin?.id || undefined
const bug = await prisma.bug.update({ const bug = await prisma.bug.update({
where: { id }, where: { id },
data: { data: { feedBack: body.feedBack },
feedBack: body.feedBack,
},
}) })
if (actingUserId) { if (actingUserId) {
@@ -488,9 +684,21 @@ export function createApp() {
} }
return bug return bug
}, {
params: t.Object({
id: t.String({ description: 'ID bug report' }),
}),
body: t.Object({
feedBack: t.String({ description: 'Feedback atau catatan developer untuk bug ini' }),
}),
detail: {
summary: 'Update Bug Feedback',
description: 'Menambahkan atau mengupdate feedback/catatan developer pada sebuah bug report.',
tags: ['Bugs'],
},
}) })
.patch('/api/bugs/:id/status', async ({ params: { id }, request }) => { .patch('/api/bugs/:id/status', async ({ params: { id }, body, request }) => {
const cookie = request.headers.get('cookie') ?? '' const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1] const token = cookie.match(/session=([^;]+)/)?.[1]
let userId: string | undefined let userId: string | undefined
@@ -502,7 +710,6 @@ export function createApp() {
} }
} }
const body = (await request.json()) as { status: string; description?: string }
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
const actingUserId = userId || defaultAdmin?.id || undefined const actingUserId = userId || defaultAdmin?.id || undefined
@@ -525,6 +732,77 @@ export function createApp() {
} }
return bug return bug
}, {
params: t.Object({
id: t.String({ description: 'ID bug report' }),
}),
body: t.Object({
status: t.Union(
[
t.Literal('OPEN'),
t.Literal('ON_HOLD'),
t.Literal('IN_PROGRESS'),
t.Literal('RESOLVED'),
t.Literal('RELEASED'),
t.Literal('CLOSED'),
],
{ description: 'Status baru bug' }
),
description: t.Optional(t.String({ description: 'Catatan perubahan status (opsional)' })),
}),
detail: {
summary: 'Update Bug Status',
description: 'Mengubah status bug dan otomatis membuat entri BugLog baru sebagai riwayat perubahan status.',
tags: ['Bugs'],
},
})
// ─── Image Upload & Proxy ──────────────────────────
.post('/api/upload/image', async ({ body, request, set }) => {
const auth = await checkAuth(request)
if (!auth) {
set.status = 401
return { error: 'Unauthorized' }
}
const { file } = body
if (!file.type.startsWith('image/')) {
set.status = 400
return { error: 'Hanya file gambar yang diperbolehkan' }
}
if (file.size > 5 * 1024 * 1024) {
set.status = 400
return { error: 'Ukuran file maksimal 5MB' }
}
const path = await uploadBugImage(file)
return { url: `/api/bugs/images?path=${encodeURIComponent(path)}` }
}, {
body: t.Object({
file: t.File({ description: 'File gambar screenshot bug (maks 5MB)' }),
}),
detail: {
summary: 'Upload Bug Image',
description: 'Upload gambar screenshot bug ke MinIO. Mengembalikan URL proxy yang dapat langsung dipakai sebagai imageUrl pada POST /api/bugs.',
tags: ['Bugs'],
},
})
.get('/api/bugs/images', async ({ query, set }) => {
try {
const downloadUrl = await getMinioDownloadUrl(query.path)
return new Response(null, { status: 302, headers: { Location: downloadUrl } })
} catch {
set.status = 404
return { error: 'Gambar tidak ditemukan' }
}
}, {
query: t.Object({
path: t.String({ description: 'Path file di Seafile (contoh: /bug-reports/uuid.jpg)' }),
}),
detail: {
summary: 'Get Bug Image',
description: 'Proxy gambar bug dari MinIO. Meredirect ke presigned download URL MinIO (valid 1 jam).',
tags: ['Bugs'],
},
}) })
// ─── System Status API ───────────────────────────── // ─── System Status API ─────────────────────────────
@@ -549,18 +827,33 @@ export function createApp() {
uptime: process.uptime(), uptime: process.uptime(),
} }
} }
}, {
detail: {
summary: 'System Status',
description: 'Memeriksa status operasional sistem: koneksi database dan jumlah sesi aktif. Mengembalikan status "degraded" jika database tidak dapat dijangkau.',
tags: ['System'],
},
}) })
// ─── Example API ─────────────────────────────────── // ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({ .get('/api/hello', () => ({
message: 'Hello, world!', message: 'Hello, world!',
method: 'GET', method: 'GET',
})) }), {
detail: { summary: 'Hello GET', tags: ['System'] },
})
.put('/api/hello', () => ({ .put('/api/hello', () => ({
message: 'Hello, world!', message: 'Hello, world!',
method: 'PUT', method: 'PUT',
})) }), {
detail: { summary: 'Hello PUT', tags: ['System'] },
})
.get('/api/hello/:name', ({ params }) => ({ .get('/api/hello/:name', ({ params }) => ({
message: `Hello, ${params.name}!`, message: `Hello, ${params.name}!`,
})) }), {
params: t.Object({
name: t.String({ description: 'Nama yang akan disapa' }),
}),
detail: { summary: 'Hello by Name', tags: ['System'] },
})
} }

View File

@@ -1,15 +1,15 @@
import { import { BarChart, LineChart } from '@mantine/charts'
Paper, import {
Stack,
Text,
Group,
ThemeIcon,
Box,
Badge, Badge,
Box,
Group,
Paper,
Stack,
Text,
ThemeIcon,
useMantineTheme useMantineTheme
} from '@mantine/core' } from '@mantine/core'
import { LineChart, BarChart } from '@mantine/charts' import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
interface ChartProps { interface ChartProps {
data?: any[] data?: any[]
@@ -18,7 +18,7 @@ interface ChartProps {
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) { export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
const theme = useMantineTheme() const theme = useMantineTheme()
return ( return (
<Paper withBorder p="xl" radius="2xl" className="glass h-full"> <Paper withBorder p="xl" radius="2xl" className="glass h-full">
<Stack gap="md" h="100%"> <Stack gap="md" h="100%">
@@ -32,9 +32,14 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
<Text size="xs" c="dimmed">Trend over the last 7 days</Text> <Text size="xs" c="dimmed">Trend over the last 7 days</Text>
</Box> </Box>
</Group> </Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}> {
{isLoading ? '...' : 'Live'} isLoading && (
</Badge> <Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
...
</Badge>
)
}
</Group> </Group>
<Box h={300} mt="lg"> <Box h={300} mt="lg">
@@ -48,6 +53,9 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
gridAxis="x" gridAxis="x"
withTooltip withTooltip
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
tooltipProps={{
allowEscapeViewBox: { x: true, y: false },
}}
styles={{ styles={{
root: { root: {
'.recharts-line-curve': { '.recharts-line-curve': {
@@ -86,17 +94,38 @@ export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps)
h={300} h={300}
data={data} data={data}
dataKey="village" dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]} series={[{ name: 'activity', color: 'blue.6' }]} // Menggunakan warna dari theme
withTooltip withTooltip
tickLine="none"
gridAxis="y"
barProps={{ barProps={{
radius: [8, 8, 4, 4], radius: [8, 8, 0, 0],
fill: 'url(#barGradient)', // Menggunakan gradient yang Anda buat
}} }}
styles={{ tooltipProps={{
bar: { cursor: { fill: '#373A40', opacity: 0.4 },
fill: 'url(#barGradient)', allowEscapeViewBox: { x: false, y: false },
content: ({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none', // Sangat penting agar tidak mengganggu hover
whiteSpace: 'nowrap' // Mencegah teks turun ke bawah
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#fff', marginBottom: '4px' }}>
{payload[0].payload.village}
</div>
<div style={{ fontSize: '11px', color: '#2563EB' }}>
Activity: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
</div>
</div>
);
} }
return null;
},
}} }}
> >
<defs> <defs>

View File

@@ -263,16 +263,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
variant="filled" variant="filled"
color="brand-blue" color="brand-blue"
className="sidebar-nav-item" className="sidebar-nav-item"
styles={(theme) => ({
root: {
borderRadius: theme.radius.md,
transition: 'all 0.2s ease',
'&[data-active]': {
background: 'var(--gradient-blue-purple)',
fontWeight: 600,
},
},
})}
/> />
) )
})} })}

View File

@@ -1,23 +1,23 @@
import { import {
Table, Badge,
Badge,
Text,
Paper,
Group,
Drawer,
Stack,
Divider,
Code,
Button,
Box, Box,
Button,
Code,
Divider,
Drawer,
Group,
Paper,
ScrollArea, ScrollArea,
Stack,
Table,
Text,
Title Title
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb' import { useState } from 'react'
import { TbBug, TbExternalLink, TbHistory, TbMessageReport } from 'react-icons/tb'
export interface ErrorDataTableProps { export interface ErrorDataTableProps {
appId?: string appId?: string
@@ -26,6 +26,7 @@ export interface ErrorDataTableProps {
export function ErrorDataTable({ appId }: ErrorDataTableProps) { export function ErrorDataTable({ appId }: ErrorDataTableProps) {
const [opened, { open, close }] = useDisclosure(false) const [opened, { open, close }] = useDisclosure(false)
const [selectedError, setSelectedError] = useState<any>(null) const [selectedError, setSelectedError] = useState<any>(null)
const [showStackTrace, setShowStackTrace] = useState(false)
const { data: bugsData, isLoading } = useQuery({ const { data: bugsData, isLoading } = useQuery({
queryKey: ['bugs', appId], queryKey: ['bugs', appId],
@@ -36,11 +37,12 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
const handleRowClick = (error: any) => { const handleRowClick = (error: any) => {
setSelectedError(error) setSelectedError(error)
setShowStackTrace(false)
open() open()
} }
const getSeverityColor = (sev: string) => { const getSeverityColor = (sev: string) => {
switch(sev?.toUpperCase()) { switch (sev?.toUpperCase()) {
case 'OPEN': return 'red' case 'OPEN': return 'red'
case 'IN_PROGRESS': return 'orange' case 'IN_PROGRESS': return 'orange'
case 'ON_HOLD': return 'yellow' case 'ON_HOLD': return 'yellow'
@@ -90,10 +92,10 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
) : bugs.map((error: any) => ( ) : bugs.map((error: any) => (
<Table.Tr <Table.Tr
key={error.id} key={error.id}
onClick={() => handleRowClick(error)} onClick={() => handleRowClick(error)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<Table.Td px="xl"> <Table.Td px="xl">
<Text size="sm" fw={600} lineClamp={1}>{error.description}</Text> <Text size="sm" fw={600} lineClamp={1}>{error.description}</Text>
@@ -107,7 +109,7 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
<Table.Td> <Table.Td>
<Group gap={6}> <Group gap={6}>
<TbHistory size={12} color="gray" /> <TbHistory size={12} color="gray" />
<Text size="xs" c="dimmed">{new Date(error.createdAt).toLocaleString()}</Text> <Text size="xs" c="dimmed">{new Date(error.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</Text>
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td pr="xl"> <Table.Td pr="xl">
@@ -146,8 +148,8 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
<SimpleGrid cols={2} spacing="lg"> <SimpleGrid cols={2} spacing="lg">
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTER</Text> <Text size="xs" fw={700} c="dimmed" mb={4}>SOURCE</Text>
<Text fw={600}>{selectedError.user?.name || selectedError.userId || 'System'}</Text> <Text fw={600}>{selectedError.source}</Text>
</Box> </Box>
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text> <Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text>
@@ -158,16 +160,23 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
<Divider opacity={0.1} /> <Divider opacity={0.1} />
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text> <Group justify="space-between" mb="sm">
<Code block color="red" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, border: '1px solid var(--mantine-color-default-border)' }}> <Text size="xs" fw={700} c="dimmed">STACK TRACE</Text>
{selectedError.stackTrace} <Button
</Code> variant="subtle"
size="compact-xs"
color="gray"
onClick={() => setShowStackTrace((v) => !v)}
>
{showStackTrace ? 'Hide' : 'Show'}
</Button>
</Group>
{showStackTrace && (
<Code block color="red" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, border: '1px solid var(--mantine-color-default-border)' }}>
{selectedError.stackTrace}
</Code>
)}
</Box> </Box>
<Group justify="flex-end" mt="xl">
<Button variant="light" color="gray" onClick={close}>Dismiss</Button>
<Button variant="gradient" gradient={{ from: 'red', to: 'orange' }}>Assign Technician</Button>
</Group>
</Stack> </Stack>
)} )}
</Drawer> </Drawer>

View File

@@ -37,6 +37,7 @@ export const API_URLS = {
getBugs: (page: number, search: string, app: string, status: string) => getBugs: (page: number, search: string, app: string, status: string) =>
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`, `/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
createBug: () => `/api/bugs`, createBug: () => `/api/bugs`,
uploadImage: () => `/api/upload/image`,
updateBugStatus: (id: string) => `/api/bugs/${id}/status`, updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`, updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
createLog: () => `/api/logs`, createLog: () => `/api/logs`,

View File

@@ -6,6 +6,7 @@ import {
Button, Button,
Code, Code,
Collapse, Collapse,
FileInput,
Group, Group,
Image, Image,
Loader, Loader,
@@ -57,11 +58,16 @@ function AppErrorsPage() {
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({}) const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
const [showStackTrace, setShowStackTrace] = useState<Record<string, boolean>>({})
const toggleLogs = (bugId: string) => { const toggleLogs = (bugId: string) => {
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] })) setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
} }
const toggleStackTrace = (bugId: string) => {
setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
}
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['bugs', { page, search, app, status }], queryKey: ['bugs', { page, search, app, status }],
queryFn: () => queryFn: () =>
@@ -74,19 +80,21 @@ function AppErrorsPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()), queryFn: () => fetch('/api/apps').then((r) => r.json()),
}) })
// Image Preview
const [previewImage, setPreviewImage] = useState<string | null>(null)
// Create Bug Modal Logic // Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false) const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
description: '', description: '',
app: appId, app: appId,
status: 'OPEN',
source: 'USER', source: 'USER',
affectedVersion: '', affectedVersion: '',
device: '', device: '',
os: '', os: '',
stackTrace: '', stackTrace: '',
imageUrl: '',
}) })
// Update Status Modal Logic // Update Status Modal Logic
@@ -189,10 +197,20 @@ function AppErrorsPage() {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const imageUrls: string[] = []
for (const file of imageFiles) {
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
const { url } = await uploadRes.json()
imageUrls.push(url)
}
const res = await fetch(API_URLS.createBug(), { const res = await fetch(API_URLS.createBug(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm), body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
}) })
if (res.ok) { if (res.ok) {
@@ -210,16 +228,15 @@ function AppErrorsPage() {
}) })
refetch() refetch()
close() close()
setImageFiles([])
setCreateForm({ setCreateForm({
description: '', description: '',
app: 'desa_plus', app: appId,
status: 'OPEN',
source: 'USER', source: 'USER',
affectedVersion: '', affectedVersion: '',
device: '', device: '',
os: '', os: '',
stackTrace: '', stackTrace: '',
imageUrl: '',
}) })
} else { } else {
throw new Error('Failed to create error report') throw new Error('Failed to create error report')
@@ -256,6 +273,28 @@ function AppErrorsPage() {
</Button> </Button>
</Group> </Group>
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
radius="xl"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<Image
src={previewImage}
alt="Preview"
fit="contain"
style={{ maxHeight: '85vh', width: '100%' }}
/>
)}
</Modal>
<Modal <Modal
opened={updateModalOpened} opened={updateModalOpened}
onClose={closeUpdateModal} onClose={closeUpdateModal}
@@ -331,7 +370,7 @@ function AppErrorsPage() {
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={() => { close(); setImageFiles([]); }}
title={<Text fw={700} size="lg">Report New Error</Text>} title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl" radius="xl"
size="lg" size="lg"
@@ -368,25 +407,13 @@ function AppErrorsPage() {
/> />
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={2}> <TextInput
<TextInput label="Version"
label="Version" placeholder="e.g. 2.4.1"
placeholder="e.g. 2.4.1" required
required value={createForm.affectedVersion}
value={createForm.affectedVersion} onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })} />
/>
<Select
label="Initial Status"
data={[
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
]}
value={createForm.status}
onChange={(val) => setCreateForm({ ...createForm, status: val as any })}
/>
</SimpleGrid>
<SimpleGrid cols={2}> <SimpleGrid cols={2}>
<TextInput <TextInput
@@ -405,11 +432,22 @@ function AppErrorsPage() {
/> />
</SimpleGrid> </SimpleGrid>
<TextInput <FileInput
label="Image URL (Optional)" label="Screenshot (Optional)"
placeholder="https://example.com/screenshot.png" placeholder="Klik untuk upload gambar..."
value={createForm.imageUrl} accept="image/*"
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })} leftSection={<TbPhoto size={16} />}
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
value={imageFiles}
onChange={(files) => {
if (files.length > 3) {
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
return
}
setImageFiles(files)
}}
clearable
multiple
/> />
<Textarea <Textarea
@@ -525,7 +563,7 @@ function AppErrorsPage() {
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{new Date(bug.createdAt).toLocaleString()} {bug.app?.toUpperCase()} v{bug.affectedVersion} {new Date(bug.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} {bug.appId?.toUpperCase()} v{bug.affectedVersion}
</Text> </Text>
</Group> </Group>
</Box> </Box>
@@ -578,19 +616,31 @@ function AppErrorsPage() {
{/* Stack Trace */} {/* Stack Trace */}
{bug.stackTrace && ( {bug.stackTrace && (
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text> <Group justify="space-between" mb={4}>
<Code <Text size="xs" fw={700} c="dimmed">STACK TRACE</Text>
block <Button
color="red" variant="subtle"
style={{ size="compact-xs"
fontFamily: 'monospace', color="gray"
whiteSpace: 'pre-wrap', onClick={() => toggleStackTrace(bug.id)}
fontSize: '11px', >
border: '1px solid var(--mantine-color-default-border)', {showStackTrace[bug.id] ? 'Hide' : 'Show'}
}} </Button>
> </Group>
{bug.stackTrace} <Collapse in={!!showStackTrace[bug.id]}>
</Code> <Code
block
color="red"
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
fontSize: '11px',
border: '1px solid var(--mantine-color-default-border)',
}}
>
{bug.stackTrace}
</Code>
</Collapse>
</Box> </Box>
)} )}
@@ -603,7 +653,13 @@ function AppErrorsPage() {
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => ( {bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}> <Paper
key={img.id}
withBorder
radius="md"
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
onClick={() => setPreviewImage(img.imageUrl)}
>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" /> <Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper> </Paper>
))} ))}

View File

@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts' import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable' import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard' import { SummaryCard } from '@/frontend/components/SummaryCard'
import { useSession } from '@/frontend/hooks/useAuth'
import { import {
Badge, Badge,
Button, Button,
@@ -39,6 +40,8 @@ function AppOverviewPage() {
const navigate = useNavigate() const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false) const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
// Form State // Form State
const [latestVersion, setLatestVersion] = useState('') const [latestVersion, setLatestVersion] = useState('')
@@ -177,7 +180,7 @@ function AppOverviewPage() {
value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')} value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
icon={TbVersions} icon={TbVersions}
color="brand-blue" color="brand-blue"
onClick={openVersionModal} onClick={isDeveloper ? openVersionModal : undefined}
> >
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Stack gap={0}> <Stack gap={0}>
@@ -220,6 +223,7 @@ function AppOverviewPage() {
icon={TbAlertTriangle} icon={TbAlertTriangle}
color="red" color="red"
isError={true} isError={true}
onClick={() => navigate({ to: `/apps/${appId}/errors` })}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -37,6 +37,7 @@ import {
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr' import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
const fetcher = (url: string) => fetch(url).then((res) => res.json()) const fetcher = (url: string) => fetch(url).then((res) => res.json())
@@ -72,7 +73,14 @@ function ActivityChart({ villageId }: { villageId: string }) {
yearly: 'Yearly', yearly: 'Yearly',
} }
const data = response?.data || [] const rawData: any[] = Array.isArray(response?.data) ? response.data : []
// Normalize: map any field names from external API → { label, activity }
const data = rawData.map((item) => {
const label = item.label
const activity = item.aktivitas
return { label: String(label), activity: Number(activity) }
})
return ( return (
<Paper withBorder radius="xl" p="lg"> <Paper withBorder radius="xl" p="lg">
@@ -109,35 +117,20 @@ function ActivityChart({ villageId }: { villageId: string }) {
h={280} h={280}
data={data} data={data}
dataKey="label" dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]} series={[{ name: 'activity', color: '#2563EB' }]}
curveType="monotone" curveType="monotone"
withTooltip withTooltip={true}
withDots withDots={true}
tickLine="none" withPointLabels={false}
gridAxis="x"
tooltipAnimationDuration={150} tooltipAnimationDuration={150}
fillOpacity={1} tooltipProps={{
areaProps={{ allowEscapeViewBox: { x: true, y: false },
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
}} }}
dotProps={{ activeDotProps={{
r: 4, r: 6,
strokeWidth: 2, strokeWidth: 2,
stroke: '#2563EB',
fill: 'white',
}} }}
> />
<defs>
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
</linearGradient>
</defs>
</AreaChart>
)} )}
</Paper> </Paper>
) )
@@ -149,6 +142,9 @@ function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' }) const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate() const navigate = useNavigate()
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher) const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher)
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher) const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
@@ -323,6 +319,7 @@ function VillageDetailPage() {
onClick={openConfirmModal} onClick={openConfirmModal}
radius="md" radius="md"
loading={isUpdating} loading={isUpdating}
disabled={!isDeveloper}
> >
{village.isActive ? 'Deactivate' : 'Active'} {village.isActive ? 'Deactivate' : 'Active'}
</Button> </Button>
@@ -440,7 +437,9 @@ function VillageDetailPage() {
}} }}
> >
{/* Left (3/4): Activity Chart */} {/* Left (3/4): Activity Chart */}
<ActivityChart villageId={villageId} /> <Box style={{ minWidth: 0 }}>
<ActivityChart villageId={villageId} />
</Box>
{/* Right (1/4): Informasi Sistem */} {/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg"> <Paper withBorder radius="xl" p="lg">
@@ -454,7 +453,7 @@ function VillageDetailPage() {
{[ {[
{ label: 'Date Created', value: village.createdAt }, { label: 'Date Created', value: village.createdAt },
{ label: 'Created By', value: '-' }, { label: 'Created By', value: '-' },
{ label: 'Last Updated', value: '-' }, { label: 'Last Updated', value: village.updatedAt },
].map((item, idx, arr) => ( ].map((item, idx, arr) => (
<Group <Group
key={item.label} key={item.label}

View File

@@ -20,6 +20,7 @@ import {
Stack, Stack,
Text, Text,
ThemeIcon, ThemeIcon,
FileInput,
TextInput, TextInput,
Textarea, Textarea,
Title, Title,
@@ -55,10 +56,14 @@ function ListErrorsPage() {
const [status, setStatus] = useState('all') const [status, setStatus] = useState('all')
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({}) const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
const [showStackTrace, setShowStackTrace] = useState<Record<string, boolean>>({})
const toggleLogs = (bugId: string) => { const toggleLogs = (bugId: string) => {
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] })) setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
} }
const toggleStackTrace = (bugId: string) => {
setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
}
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['bugs', { page, search, app, status }], queryKey: ['bugs', { page, search, app, status }],
@@ -72,19 +77,21 @@ function ListErrorsPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()), queryFn: () => fetch('/api/apps').then((r) => r.json()),
}) })
// Image Preview
const [previewImage, setPreviewImage] = useState<string | null>(null)
// Create Bug Modal Logic // Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false) const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
description: '', description: '',
app: 'desa-plus', app: 'desa-plus',
status: 'OPEN',
source: 'USER', source: 'USER',
affectedVersion: '', affectedVersion: '',
device: '', device: '',
os: '', os: '',
stackTrace: '', stackTrace: '',
imageUrl: '',
}) })
// Update Status Modal Logic // Update Status Modal Logic
@@ -187,10 +194,20 @@ function ListErrorsPage() {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const imageUrls: string[] = []
for (const file of imageFiles) {
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
const { url } = await uploadRes.json()
imageUrls.push(url)
}
const res = await fetch(API_URLS.createBug(), { const res = await fetch(API_URLS.createBug(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm), body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
}) })
if (res.ok) { if (res.ok) {
@@ -208,16 +225,15 @@ function ListErrorsPage() {
}) })
refetch() refetch()
close() close()
setImageFiles([])
setCreateForm({ setCreateForm({
description: '', description: '',
app: 'desa-plus', app: 'desa-plus',
status: 'OPEN',
source: 'USER', source: 'USER',
affectedVersion: '', affectedVersion: '',
device: '', device: '',
os: '', os: '',
stackTrace: '', stackTrace: '',
imageUrl: '',
}) })
} else { } else {
throw new Error('Failed to create error report') throw new Error('Failed to create error report')
@@ -265,6 +281,28 @@ function ListErrorsPage() {
</Group> </Group>
</Group> </Group>
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
radius="xl"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<Image
src={previewImage}
alt="Preview"
fit="contain"
style={{ maxHeight: '85vh', width: '100%' }}
/>
)}
</Modal>
<Modal <Modal
opened={updateModalOpened} opened={updateModalOpened}
onClose={closeUpdateModal} onClose={closeUpdateModal}
@@ -340,7 +378,7 @@ function ListErrorsPage() {
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={() => { close(); setImageFiles([]); }}
title={<Text fw={700} size="lg">Report New Error</Text>} title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl" radius="xl"
size="lg" size="lg"
@@ -377,25 +415,13 @@ function ListErrorsPage() {
/> />
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={2}> <TextInput
<TextInput label="Version"
label="Version" placeholder="e.g. 2.4.1"
placeholder="e.g. 2.4.1" required
required value={createForm.affectedVersion}
value={createForm.affectedVersion} onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })} />
/>
<Select
label="Initial Status"
data={[
{ value: 'OPEN', label: 'Open' },
{ value: 'ON_HOLD', label: 'On Hold' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
]}
value={createForm.status}
onChange={(val) => setCreateForm({ ...createForm, status: val as any })}
/>
</SimpleGrid>
<SimpleGrid cols={2}> <SimpleGrid cols={2}>
<TextInput <TextInput
@@ -414,11 +440,22 @@ function ListErrorsPage() {
/> />
</SimpleGrid> </SimpleGrid>
<TextInput <FileInput
label="Image URL (Optional)" label="Screenshot (Optional)"
placeholder="https://example.com/screenshot.png" placeholder="Klik untuk upload gambar..."
value={createForm.imageUrl} accept="image/*"
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })} leftSection={<TbPhoto size={16} />}
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
value={imageFiles}
onChange={(files) => {
if (files.length > 3) {
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
return
}
setImageFiles(files)
}}
clearable
multiple
/> />
<Textarea <Textarea
@@ -545,7 +582,7 @@ function ListErrorsPage() {
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{new Date(bug.createdAt).toLocaleString()} {bug.app?.toUpperCase()} v{bug.affectedVersion} {new Date(bug.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} {bug.appId?.toUpperCase()} v{bug.affectedVersion}
</Text> </Text>
</Group> </Group>
</Box> </Box>
@@ -598,19 +635,31 @@ function ListErrorsPage() {
{/* Stack Trace */} {/* Stack Trace */}
{bug.stackTrace && ( {bug.stackTrace && (
<Box> <Box>
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text> <Group justify="space-between" mb={showStackTrace[bug.id] ? 8 : 0}>
<Code <Text size="xs" fw={700} c="dimmed">STACK TRACE</Text>
block <Button
color="red" variant="subtle"
style={{ size="compact-xs"
fontFamily: 'monospace', color="gray"
whiteSpace: 'pre-wrap', onClick={() => toggleStackTrace(bug.id)}
fontSize: '11px', >
border: '1px solid var(--mantine-color-default-border)', {showStackTrace[bug.id] ? 'Hide' : 'Show'}
}} </Button>
> </Group>
{bug.stackTrace} <Collapse in={showStackTrace[bug.id]}>
</Code> <Code
block
color="red"
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
fontSize: '11px',
border: '1px solid var(--mantine-color-default-border)',
}}
>
{bug.stackTrace}
</Code>
</Collapse>
</Box> </Box>
)} )}
@@ -623,7 +672,13 @@ function ListErrorsPage() {
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => ( {bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}> <Paper
key={img.id}
withBorder
radius="md"
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
onClick={() => setPreviewImage(img.imageUrl)}
>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" /> <Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper> </Paper>
))} ))}

View File

@@ -1,48 +1,47 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { import {
ActionIcon, ActionIcon,
Avatar,
Badge, Badge,
Button, Button,
Card, Card,
Container, Container,
Divider,
Group, Group,
List,
Modal,
Pagination,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack, Stack,
Table, Table,
Tabs,
Text, Text,
TextInput, TextInput,
Title,
Paper,
Tabs,
Avatar,
SimpleGrid,
ThemeIcon, ThemeIcon,
List, Title,
Divider,
Pagination,
Modal,
Select,
PasswordInput,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications' import { notifications } from '@mantine/notifications'
import { import { createFileRoute } from '@tanstack/react-router'
TbPlus, import { useEffect, useState } from 'react'
TbSearch, import {
TbPencil,
TbTrash,
TbUserCheck,
TbShieldCheck,
TbAccessPoint, TbAccessPoint,
TbCircleCheck, TbCircleCheck,
TbCircleX, TbCircleX,
TbClock, TbPencil,
TbApps, TbPlus,
TbSearch,
TbShieldCheck,
TbTrash,
TbUserCheck
} from 'react-icons/tb' } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import useSWR from 'swr' import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
export const Route = createFileRoute('/users')({ export const Route = createFileRoute('/users')({
component: UsersPage, component: UsersPage,
@@ -59,13 +58,13 @@ const getRoleColor = (role: string) => {
} }
const roles = [ const roles = [
{ {
name: 'DEVELOPER', name: 'DEVELOPER',
color: 'red', color: 'red',
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management'] permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
}, },
{ {
name: 'ADMIN', name: 'ADMIN',
color: 'orange', color: 'orange',
permissions: ['View All Apps', 'View Logs', 'Report Errors'] permissions: ['View All Apps', 'View Logs', 'Report Errors']
}, },
@@ -75,6 +74,8 @@ function UsersPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300) const timer = setTimeout(() => setDebouncedSearch(search), 300)
@@ -244,15 +245,17 @@ function UsersPage() {
setPage(1) setPage(1)
}} }}
/> />
<Button {isDeveloper && (
variant="gradient" <Button
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} variant="gradient"
leftSection={<TbPlus size={18} />} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
radius="md" leftSection={<TbPlus size={18} />}
onClick={openCreate} radius="md"
> onClick={openCreate}
Add New User >
</Button> Add New User
</Button>
)}
</Group> </Group>
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}> <Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
@@ -302,12 +305,12 @@ function UsersPage() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
<ActionIcon variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}> <ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} /> <TbPencil size={14} />
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}> <ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} /> <TbTrash size={14} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -340,14 +343,14 @@ function UsersPage() {
<TbShieldCheck size={28} /> <TbShieldCheck size={28} />
</ThemeIcon> </ThemeIcon>
</Group> </Group>
<Stack gap={4}> <Stack gap={4}>
<Title order={4}>{role.name.replace('_', ' ')}</Title> <Title order={4}>{role.name.replace('_', ' ')}</Title>
<Text size="sm" c="dimmed">Core role for secure app management.</Text> <Text size="sm" c="dimmed">Core role for secure app management.</Text>
</Stack> </Stack>
<Divider /> <Divider />
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text> <Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
<List <List
spacing="xs" spacing="xs"
@@ -364,9 +367,9 @@ function UsersPage() {
))} ))}
</List> </List>
<Button fullWidth variant="light" color={role.color} mt="md" radius="md"> {/* <Button fullWidth variant="light" color={role.color} mt="md" radius="md">
Edit Permissions Edit Permissions
</Button> </Button> */}
</Stack> </Stack>
</Card> </Card>
))} ))}

View File

@@ -84,7 +84,7 @@ body {
transition: var(--transition-smooth); transition: var(--transition-smooth);
} }
.sidebar-nav-item.active { .sidebar-nav-item[data-active] {
background: var(--gradient-blue-purple); background: var(--gradient-blue-purple);
color: white; color: white;
} }

View File

@@ -7,7 +7,7 @@ import { env } from './lib/env'
const isProduction = env.NODE_ENV === 'production' const isProduction = env.NODE_ENV === 'production'
// ─── Route Classification ────────────────────────────── // ─── Route Classification ──────────────────────────────
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health'] const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health', '/docs']
function isApiRoute(pathname: string): boolean { function isApiRoute(pathname: string): boolean {
return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health' return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health'

View File

@@ -16,4 +16,12 @@ export const env = {
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'), GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'), GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean), SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
API_KEY: required('API_KEY'),
MINIO_ENDPOINT: required('MINIO_ENDPOINT'),
MINIO_PORT: parseInt(optional('MINIO_PORT', '443'), 10),
MINIO_USE_SSL: optional('MINIO_USE_SSL', 'true') === 'true',
MINIO_ACCESS_KEY: required('MINIO_ACCESS_KEY'),
MINIO_SECRET_KEY: required('MINIO_SECRET_KEY'),
MINIO_BUCKET: required('MINIO_BUCKET'),
MINIO_UPLOAD_DIR: optional('MINIO_UPLOAD_DIR', 'bug-reports'),
} as const } as const

36
src/lib/minio.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Client } from 'minio'
import { env } from './env'
const client = new Client({
endPoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT,
useSSL: env.MINIO_USE_SSL,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
})
// Auto-create bucket if it doesn't exist
client.bucketExists(env.MINIO_BUCKET).then(async (exists) => {
if (!exists) {
await client.makeBucket(env.MINIO_BUCKET)
console.log(`[MinIO] Bucket "${env.MINIO_BUCKET}" created.`)
}
}).catch((err) => {
console.error('[MinIO] Failed to check/create bucket:', err.message)
})
export async function uploadBugImage(file: File): Promise<string> {
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin'
const objectName = `${env.MINIO_UPLOAD_DIR}/${crypto.randomUUID()}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
await client.putObject(env.MINIO_BUCKET, objectName, buffer, file.size, {
'Content-Type': file.type,
})
return objectName // e.g. bug-reports/uuid.jpg
}
export async function getMinioDownloadUrl(objectName: string): Promise<string> {
return client.presignedGetObject(env.MINIO_BUCKET, objectName, 3600)
}

252
src/lib/seafile.ts Normal file
View File

@@ -0,0 +1,252 @@
#!/usr/bin/env bun
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
// --- Constants ---
const CONFIG_FILE = path.join(os.homedir(), '.note.conf');
// --- Types ---
interface Config {
TOKEN?: string;
REPO?: string;
URL?: string;
}
export const defaultConfigSF: Config = {
TOKEN: process.env.SF_TOKEN,
REPO: process.env.SF_REPO,
URL: process.env.SF_URL,
}
export async function loadConfig(): Promise<Config> {
if (!(await fs.stat(CONFIG_FILE)).isFile()) {
console.error(`⚠️ Config file not found at ${CONFIG_FILE}`);
console.error('Run: bun note.ts config to create/edit it.');
process.exit(1);
}
const configContent = await fs.readFile(CONFIG_FILE, 'utf8');
const config: Config = {};
configContent.split('\n').forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const [key, ...valueParts] = trimmed.split('=');
if (key && valueParts.length > 0) {
let value = valueParts.join('=').trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
config[key as keyof Config] = value;
}
});
if (!config.TOKEN || !config.REPO || !config.URL) {
console.error(`❌ Config invalid. Please set TOKEN, REPO, and URL inside ${CONFIG_FILE}`);
process.exit(1);
}
return config;
}
// --- HTTP Helpers ---
export async function fetchWithAuth(config: Config, url: string, options: RequestInit = {}): Promise<Response> {
const headers = {
Authorization: `Token ${config.TOKEN}`,
...options.headers,
};
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
console.error(`❌ Request failed: ${response.status} ${response.statusText}`);
console.error(`🔍 URL: ${url}`);
console.error(`🔍 Headers:`, headers);
try {
const errorText = await response.text();
console.error(`🔍 Response body: ${errorText}`);
} catch {
console.error('🔍 Could not read response body');
}
}
return response;
}
// --- Commands ---
export async function testConnection(config: Config): Promise<string> {
try {
const response = await fetchWithAuth(config, `${config.URL}/ping/`);
return `✅ API connection successful: ${await response.text()}`
} catch {
// return '⚠️ API ping failed, trying repo access...'
try {
await fetchWithAuth(config, `${config.URL}/${config.REPO}/`);
return `✅ Repo access successful`
} catch {
return '❌ Both API ping and repo access failed'
}
}
}
export async function listFiles(config: Config): Promise<{ name: string }[]> {
const url = `${config.URL}/${config.REPO}/dir/?p=/`;
const response = await fetchWithAuth(config, url);
try {
const files = (await response.json()) as { name: string }[];
return files
} catch {
console.error('❌ Failed to parse response');
process.exit(1);
}
}
export async function catFile(config: Config, folder: string, fileName: string): Promise<ArrayBuffer> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`);
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
// Download file sebagai binary, BUKAN text
const fileResponse = await fetchWithAuth(config, downloadUrl);
const buffer = await fileResponse.arrayBuffer();
return buffer;
}
export async function uploadFile(config: Config, file: File, folder: string): Promise<string> {
const remoteName = path.basename(file.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 3. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) return 'gagal'
return `✅ Uploaded ${file.name} successfully`;
}
export async function uploadFileBase64(config: Config, base64File: { name: string; data: string; }): Promise<string> {
const remoteName = path.basename(base64File.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Konversi base64 ke Blob
const binary = Buffer.from(base64File.data, "base64");
const blob = new Blob([binary]);
// 3. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
formData.append("file", blob, remoteName);
// 4. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${base64File.name} successfully`;
}
export async function uploadFileToFolder(config: Config, base64File: { name: string; data: string; }, folder: 'syarat-dokumen' | 'pengaduan'): Promise<string> {
const remoteName = path.basename(base64File.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Konversi base64 ke Blob
const binary = Buffer.from(base64File.data, "base64");
const blob = new Blob([binary]);
// 3. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", blob, remoteName);
// 4. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${base64File.name} successfully`;
}
export async function removeFile(config: Config, fileName: string, folder: string): Promise<string> {
const res = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`, { method: 'DELETE' });
if (!res.ok) return 'gagal menghapus file';
return `🗑️ Removed ${fileName}`
}
export async function moveFile(config: Config, oldName: string, newName: string): Promise<string> {
const url = `${config.URL}/${config.REPO}/file/?p=/${oldName}`;
const formData = new FormData();
formData.append('operation', 'rename');
formData.append('newname', newName);
await fetchWithAuth(config, url, { method: 'POST', body: formData });
return `✏️ Renamed ${oldName}${newName}`
}
export async function downloadFile(config: Config, fileName: string, folder: string, localFile?: string): Promise<string> {
const localName = localFile || fileName;
// 🔹 gabungkan path folder + file
const filePath = `/${folder}/${fileName}`.replace(/\/+/g, "/");
// 🔹 encode path agar aman (spasi, dll)
const params = new URLSearchParams({
p: filePath,
});
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?${params.toString()}`);
if (!downloadUrlResponse.ok)
return 'gagal'
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
const buffer = Buffer.from(await (await fetchWithAuth(config, downloadUrl)).arrayBuffer());
await fs.writeFile(localName, buffer);
return `⬇️ Downloaded ${fileName}${localName}`
}
export async function getFileLink(config: Config, fileName: string): Promise<string> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`);
return `🔗 Link for ${fileName}:\n${(await downloadUrlResponse.text()).replace(/"/g, '')}`
}