Compare commits
62 Commits
0998f65c6f
...
feature/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| fb925a9dce | |||
| 70e7cd2f0f | |||
| 33f735af67 | |||
| f8a1a00e0a | |||
| 27c36b6b9a | |||
| 684cfd3789 | |||
| 52751ae9d4 | |||
| 3fc737c872 | |||
| b993a0a831 | |||
| a8696c2a85 | |||
| 15f146ee89 | |||
| 8c1fe47a99 | |||
| b9a06dd244 | |||
| 818db73432 | |||
| 1a5e6a303e | |||
| 923a0f66b0 | |||
| 1b492f2ac2 | |||
| 70466a9a1c | |||
| 5e0771d929 | |||
| 70211bdc57 | |||
| 35989f8120 | |||
| b974675b11 | |||
| c4ce96ce4f | |||
| 60db8bd9de | |||
| ecbfbc00e9 | |||
| f7ce380104 | |||
| 0d7c4f476a | |||
| 86a4a747b5 | |||
| e9d33e59e9 | |||
| 5308991123 | |||
| a6e7035aab | |||
| 0eaf401cce | |||
| a3061b22ca | |||
| 1dff6abb3b | |||
| 2dddba9a08 | |||
| 41a2910aeb | |||
| ecff58500e | |||
| 3016eb1a1a | |||
| 4f434f39bf | |||
| 6ae41c4ffa | |||
| be168c8329 | |||
| 9191f0fe24 | |||
| e34a2cad11 | |||
| 790fc07f5a | |||
| 4148833644 | |||
| 17d76761bb | |||
| ba9662aeaf | |||
| 6f51432d42 | |||
| 8919829167 | |||
| a10156142f | |||
| 5bb728e545 | |||
| 511fece4c7 | |||
| 87a367d41b | |||
| 66dc8ec8ee | |||
| e0e7bfce3e | |||
| 8138458d8d | |||
| 7c4fa9d9d2 | |||
| 32c7b41ce5 | |||
| b3a13fa974 | |||
| 0004329895 | |||
| d104e9788f | |||
| 1eb4a786ce |
@@ -2,9 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [Develop]
|
||||
pull_request:
|
||||
branches: [Develop]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
@@ -26,3 +24,33 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
e2e:
|
||||
needs: ci
|
||||
runs-on: docker
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
apt-get update && apt-get install -y unzip
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build client
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
bun run build
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
CI=true bun run test:e2e
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -226,6 +226,11 @@ uploads/*
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Playwright
|
||||
e2e/test.db
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -80,13 +80,13 @@ Replaces spreadsheet-based gear tracking workflow.
|
||||
- **Runtime**: Bun — used as package manager and runtime
|
||||
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
||||
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
||||
- **Scope**: No auth, single user for v1
|
||||
- **Scope**: Single user with cookie/API key auth
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| No auth for v1 | Single user, simplicity first | ✓ Good |
|
||||
| Cookie/API key auth | Single user, public read + authenticated write | ✓ Good |
|
||||
| Generic data model | Support any hobby, not just bikepacking | ✓ Good |
|
||||
| Dashboard navigation | Clean entry point, not persistent nav | ✓ Good |
|
||||
| Bun runtime | User preference | ✓ Good |
|
||||
|
||||
97
CLAUDE.md
97
CLAUDE.md
@@ -19,8 +19,10 @@ bun run db:generate # Generate Drizzle migration from schema changes
|
||||
bun run db:push # Apply migrations to gearbox.db
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests
|
||||
bun test # Run all unit/integration tests
|
||||
bun test tests/services/item.service.test.ts # Run single test file
|
||||
bun run test:e2e # Run Playwright E2E tests
|
||||
bun run test:e2e:ui # Playwright UI mode for debugging
|
||||
|
||||
# Lint & Format
|
||||
bun run lint # Biome check (tabs, double quotes, organized imports)
|
||||
@@ -53,11 +55,18 @@ bun run build # Vite build → dist/client/
|
||||
- **Schema**: `schema.ts` — Drizzle table definitions for SQLite.
|
||||
- **Prices stored as cents** (`priceCents: integer`) to avoid float rounding.
|
||||
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
|
||||
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`.
|
||||
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`, `users`, `sessions`, `apiKeys`.
|
||||
|
||||
### Testing (`tests/`)
|
||||
- Bun test runner. Tests at service level and route level.
|
||||
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite with full schema and seeds an "Uncategorized" category. When adding schema columns, update both `src/db/schema.ts` and the test helper's CREATE TABLE statements.
|
||||
### Testing (`tests/` and `e2e/`)
|
||||
- **Unit/integration**: Bun test runner (`bun test`). Tests at service level and route level.
|
||||
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite via Drizzle migrations and seeds an "Uncategorized" category.
|
||||
- **E2E**: Playwright (`bun run test:e2e`). Tests in `e2e/` run against a seeded SQLite database with the server in production mode. Seed script: `e2e/seed.ts`.
|
||||
|
||||
## Branching
|
||||
|
||||
- **Develop** is the main branch. Keep it clean — don't commit large feature work directly.
|
||||
- For each new brainstorming/implementation session, create a feature branch off Develop (e.g., `feature/setup-impact-preview`, `fix/error-handling`).
|
||||
- Merge back to Develop via PR or fast-forward merge when the work is complete and verified.
|
||||
|
||||
## Path Alias
|
||||
|
||||
@@ -68,4 +77,80 @@ bun run build # Vite build → dist/client/
|
||||
- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved".
|
||||
- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert).
|
||||
- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records.
|
||||
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
|
||||
- **Image URL fetching**: `POST /api/images/from-url` fetches an image from a URL, saves locally, returns `{ filename, sourceUrl }`.
|
||||
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
|
||||
- **Authentication**: Public-read, authenticated-write. Cookie sessions for web UI, API keys (`X-API-Key` header) for programmatic access. `POST /api/auth/setup` for first-time account creation. Auth middleware protects all POST/PUT/DELETE on `/api/*` except `/api/auth/*`.
|
||||
|
||||
## Authentication
|
||||
|
||||
- **First run**: No users exist. Visit `/login` to create your admin account.
|
||||
- **Web UI**: Cookie-based sessions (`gearbox_session`), 30-day expiry, auto-refreshed.
|
||||
- **Programmatic access**: API keys created in Settings > API Keys. Pass via `X-API-Key` header.
|
||||
- **Public read**: All GET endpoints work without auth. POST/PUT/DELETE require auth.
|
||||
- **Auth routes**: `/api/auth/login`, `/api/auth/logout`, `/api/auth/setup`, `/api/auth/me`, `/api/auth/password`, `/api/auth/keys`.
|
||||
|
||||
## MCP Server
|
||||
|
||||
GearBox includes a built-in MCP server for integration with Claude Code and Claude Desktop. Enabled by default, disable with `GEARBOX_MCP=false`. Authenticated via API key.
|
||||
|
||||
### Tools (19 total)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_items` | List all items, optionally filter by category |
|
||||
| `get_item` | Get item details by ID |
|
||||
| `create_item` | Add item to collection (for decided items; use `create_thread` for research) |
|
||||
| `update_item` | Update item details |
|
||||
| `delete_item` | Remove item from collection |
|
||||
| `list_categories` | List all categories |
|
||||
| `create_category` | Create a new category |
|
||||
| `list_threads` | List research threads (recommended workflow for gear purchases) |
|
||||
| `get_thread` | Get thread with candidates |
|
||||
| `create_thread` | Start a research thread to compare gear options |
|
||||
| `resolve_thread` | Pick winning candidate, adds it to collection |
|
||||
| `add_candidate` | Add candidate to a research thread |
|
||||
| `update_candidate` | Update candidate details |
|
||||
| `remove_candidate` | Remove candidate from thread |
|
||||
| `list_setups` | List gear setups |
|
||||
| `get_setup` | Get setup with items and totals |
|
||||
| `create_setup` | Create a new setup |
|
||||
| `update_setup` | Update setup name or items |
|
||||
| `upload_image_from_url` | Fetch image from URL, save locally |
|
||||
|
||||
### Resources
|
||||
|
||||
- `gearbox://collection/summary` — Overview of collection: totals, items per category, active threads.
|
||||
|
||||
### Configuration
|
||||
|
||||
**Claude Code** (`.claude/settings.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "<your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Claude Desktop** (`claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "<your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Generate an API key from Settings > API Keys after logging in.
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!src/client/routeTree.gen.ts"]
|
||||
"includes": ["**", "!src/client/routeTree.gen.ts", "!drizzle", "!.planning"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
185
bun.lock
185
bun.lock
@@ -6,11 +6,13 @@
|
||||
"name": "gearbox",
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hono": "^4.12.8",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
@@ -22,6 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@tanstack/react-router-devtools": "^1.166.7",
|
||||
"@tanstack/router-plugin": "^1.166.9",
|
||||
@@ -161,6 +164,8 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -173,12 +178,16 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
|
||||
@@ -311,8 +320,14 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
@@ -337,6 +352,8 @@
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -347,6 +364,12 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
@@ -365,10 +388,22 @@
|
||||
|
||||
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
@@ -401,6 +436,8 @@
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
@@ -409,14 +446,26 @@
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
@@ -425,26 +474,56 @@
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
@@ -453,12 +532,22 @@
|
||||
|
||||
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
@@ -469,6 +558,10 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"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=="],
|
||||
@@ -479,14 +572,24 @@
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -519,40 +622,82 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-abi": ["node-abi@3.88.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"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.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
@@ -577,26 +722,50 @@
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||
|
||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||
@@ -607,6 +776,8 @@
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"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=="],
|
||||
@@ -633,6 +804,8 @@
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
@@ -641,10 +814,14 @@
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
@@ -653,12 +830,16 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -673,6 +854,8 @@
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
@@ -703,6 +886,8 @@
|
||||
|
||||
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
root = "tests/"
|
||||
642
docs/api.md
Normal file
642
docs/api.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# API Reference
|
||||
|
||||
Base URL: `http://localhost:3000`
|
||||
|
||||
**Auth:** GET endpoints are public. POST, PUT, PATCH, and DELETE require authentication via session cookie or `X-API-Key` header. See [authentication.md](authentication.md) for details.
|
||||
|
||||
**Prices** are stored and returned in cents (e.g., `2500` = $25.00).
|
||||
**Weights** are in grams.
|
||||
**Timestamps** are unix epoch integers.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Health](#health)
|
||||
- [Items](#items)
|
||||
- [Categories](#categories)
|
||||
- [Threads](#threads)
|
||||
- [Setups](#setups)
|
||||
- [Images](#images)
|
||||
- [Settings](#settings)
|
||||
- [Totals](#totals)
|
||||
- [Auth](#auth)
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
Returns server status. Always public.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Items
|
||||
|
||||
### `GET /api/items`
|
||||
|
||||
List all items in the collection.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Revelate Tangle Frame Bag",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 185,
|
||||
"priceCents": 12000,
|
||||
"notes": "Medium size, fits 2020 Surly Straggler",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": "1710000000000-uuid.jpg",
|
||||
"imageSourceUrl": "https://example.com/image.jpg",
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/items/:id`
|
||||
|
||||
Get a single item by ID.
|
||||
|
||||
**Response:** item object (see above), or `404 { "error": "Item not found" }`.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/items`
|
||||
|
||||
Create a new item. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Revelate Tangle Frame Bag",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 185,
|
||||
"priceCents": 12000,
|
||||
"notes": "Medium size",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": "1710000000000-uuid.jpg",
|
||||
"imageSourceUrl": "https://example.com/image.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------|---------|----------|--------------------------------------|
|
||||
| `name` | string | yes | Item name |
|
||||
| `categoryId` | integer | yes | ID of an existing category |
|
||||
| `weightGrams` | number | no | Weight in grams (non-negative) |
|
||||
| `priceCents` | integer | no | Price in cents (non-negative) |
|
||||
| `notes` | string | no | Free-text notes |
|
||||
| `productUrl` | string | no | URL to product page |
|
||||
| `imageFilename` | string | no | Filename from a prior image upload |
|
||||
| `imageSourceUrl`| string | no | Original URL the image came from |
|
||||
|
||||
**Response:** `201` — created item object.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/items/:id`
|
||||
|
||||
Update an existing item. All fields are optional. Auth required.
|
||||
|
||||
**Request:** same fields as POST, all optional.
|
||||
|
||||
**Response:** updated item object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/items/:id`
|
||||
|
||||
Delete an item and clean up its image file if one exists. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
Returns `404` if item not found.
|
||||
|
||||
---
|
||||
|
||||
## Categories
|
||||
|
||||
### `GET /api/categories`
|
||||
|
||||
List all categories.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "Uncategorized", "icon": "package" },
|
||||
{ "id": 2, "name": "Bags", "icon": "backpack" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/categories`
|
||||
|
||||
Create a new category. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bags", "icon": "backpack" }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|--------------------------------------|
|
||||
| `name` | string | yes | Category name |
|
||||
| `icon` | string | no | Icon name (defaults to `"package"`) |
|
||||
|
||||
**Response:** `201` — created category object.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/categories/:id`
|
||||
|
||||
Update a category. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Carry", "icon": "bag" }
|
||||
```
|
||||
|
||||
**Response:** updated category object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/categories/:id`
|
||||
|
||||
Delete a category. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threads
|
||||
|
||||
Research threads track multiple candidate options for a single gear slot before committing to a purchase.
|
||||
|
||||
### `GET /api/threads`
|
||||
|
||||
List threads. By default only active (unresolved) threads are returned.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-------------------|---------|---------|------------------------------------|
|
||||
| `includeResolved` | boolean | `false` | Set to `true` to include resolved threads |
|
||||
|
||||
**Example:** `GET /api/threads?includeResolved=true`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Handlebar bag",
|
||||
"categoryId": 2,
|
||||
"status": "active",
|
||||
"resolvedCandidateId": null,
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/threads`
|
||||
|
||||
Create a new research thread. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Handlebar bag", "categoryId": 2 }
|
||||
```
|
||||
|
||||
**Response:** `201` — created thread object.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/threads/:id`
|
||||
|
||||
Get a thread with all its candidates.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Handlebar bag",
|
||||
"categoryId": 2,
|
||||
"status": "active",
|
||||
"resolvedCandidateId": null,
|
||||
"candidates": [
|
||||
{
|
||||
"id": 10,
|
||||
"threadId": 1,
|
||||
"name": "Revelate Sweetroll",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 290,
|
||||
"priceCents": 13500,
|
||||
"notes": "16L, fits most drop bars",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": null,
|
||||
"imageSourceUrl": null,
|
||||
"status": "researching",
|
||||
"pros": "Waterproof, large capacity",
|
||||
"cons": "Pricey",
|
||||
"sortOrder": 0,
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Returns `404` if thread not found.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/threads/:id`
|
||||
|
||||
Update a thread's name or category. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bar bag", "categoryId": 2 }
|
||||
```
|
||||
|
||||
**Response:** updated thread object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/threads/:id`
|
||||
|
||||
Delete a thread and all its candidates. Cleans up any candidate image files. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/threads/:id/candidates`
|
||||
|
||||
Add a candidate to a thread. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Revelate Sweetroll",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 290,
|
||||
"priceCents": 13500,
|
||||
"notes": "16L capacity",
|
||||
"productUrl": "https://revelatedesigns.com/...",
|
||||
"imageFilename": "1710000000000-uuid.jpg",
|
||||
"imageSourceUrl": "https://example.com/image.jpg",
|
||||
"status": "researching",
|
||||
"pros": "Waterproof, large capacity",
|
||||
"cons": "Expensive"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------|---------|----------|----------------------------------------------------|
|
||||
| `name` | string | yes | Candidate name |
|
||||
| `categoryId` | integer | yes | Category ID |
|
||||
| `weightGrams` | number | no | Weight in grams |
|
||||
| `priceCents` | integer | no | Price in cents |
|
||||
| `notes` | string | no | Notes |
|
||||
| `productUrl` | string | no | Product URL |
|
||||
| `imageFilename` | string | no | Filename from a prior image upload |
|
||||
| `imageSourceUrl`| string | no | Original image URL |
|
||||
| `status` | string | no | `"researching"`, `"ordered"`, or `"arrived"` |
|
||||
| `pros` | string | no | Pros of this option |
|
||||
| `cons` | string | no | Cons of this option |
|
||||
|
||||
**Response:** `201` — created candidate object. Returns `404` if thread not found.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/threads/:threadId/candidates/:candidateId`
|
||||
|
||||
Update a candidate. All fields optional. Auth required.
|
||||
|
||||
**Request:** same fields as POST, all optional.
|
||||
|
||||
**Response:** updated candidate object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/threads/:threadId/candidates/:candidateId`
|
||||
|
||||
Delete a candidate and clean up its image. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/threads/:id/candidates/reorder`
|
||||
|
||||
Reorder candidates within a thread. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "orderedIds": [12, 10, 11] }
|
||||
```
|
||||
|
||||
`orderedIds` is an array of candidate IDs in the desired display order. All IDs must belong to the thread.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
Returns `400` with an error message if validation fails.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/threads/:id/resolve`
|
||||
|
||||
Resolve a thread by selecting the winning candidate. Auth required.
|
||||
|
||||
The winning candidate's data is copied into a new item in the collection. The thread status is set to `"resolved"` and `resolvedCandidateId` is set.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "candidateId": 10 }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"item": {
|
||||
"id": 42,
|
||||
"name": "Revelate Sweetroll",
|
||||
"categoryId": 2,
|
||||
"weightGrams": 290,
|
||||
"priceCents": 13500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `400` if the candidate does not belong to the thread or another error occurs.
|
||||
|
||||
---
|
||||
|
||||
## Setups
|
||||
|
||||
Setups are named collections of items (e.g., "Bikepacking weekend", "Commute loadout").
|
||||
|
||||
### `GET /api/setups`
|
||||
|
||||
List all setups with item counts and weight/cost totals.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Bikepacking weekend",
|
||||
"itemCount": 12,
|
||||
"totalWeightGrams": 4800,
|
||||
"totalPriceCents": 285000,
|
||||
"createdAt": 1710000000,
|
||||
"updatedAt": 1710000000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/setups`
|
||||
|
||||
Create a new setup. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bikepacking weekend" }
|
||||
```
|
||||
|
||||
**Response:** `201` — created setup object.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/setups/:id`
|
||||
|
||||
Get a setup with its full item list.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Bikepacking weekend",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Revelate Tangle Frame Bag",
|
||||
"weightGrams": 185,
|
||||
"priceCents": 12000,
|
||||
"categoryId": 2,
|
||||
"classification": "base"
|
||||
}
|
||||
],
|
||||
"totalWeightGrams": 185,
|
||||
"totalPriceCents": 12000
|
||||
}
|
||||
```
|
||||
|
||||
Returns `404` if setup not found.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/setups/:id`
|
||||
|
||||
Update a setup's name. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Bikepacking overnighter" }
|
||||
```
|
||||
|
||||
**Response:** updated setup object, or `404`.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/setups/:id/items`
|
||||
|
||||
Replace all items in a setup atomically. Deletes all existing setup-item associations and re-inserts with the provided IDs. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "itemIds": [1, 5, 12, 17] }
|
||||
```
|
||||
|
||||
Pass an empty array to remove all items from the setup.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/setups/:id`
|
||||
|
||||
Delete a setup. Does not delete the items themselves. Auth required.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Images
|
||||
|
||||
### `POST /api/images`
|
||||
|
||||
Upload an image file. Auth required.
|
||||
|
||||
**Request:** `multipart/form-data` with an `image` field.
|
||||
|
||||
```
|
||||
POST /api/images
|
||||
Content-Type: multipart/form-data; boundary=...
|
||||
|
||||
--boundary
|
||||
Content-Disposition: form-data; name="image"; filename="bag.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<binary data>
|
||||
--boundary--
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Accepted types: `image/jpeg`, `image/png`, `image/webp`
|
||||
- Maximum size: 5MB
|
||||
- Files are saved to `./uploads/` with a UUID-based filename
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{ "filename": "1710000000000-550e8400-e29b-41d4-a716-446655440000.jpg" }
|
||||
```
|
||||
|
||||
Use the returned `filename` as `imageFilename` when creating or updating items or candidates.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/images/from-url`
|
||||
|
||||
Fetch an image from a remote URL and save it locally. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "url": "https://example.com/product-image.jpg" }
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{
|
||||
"filename": "1710000000000-uuid.jpg",
|
||||
"sourceUrl": "https://example.com/product-image.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
Returns `400` for invalid URLs, unsupported content types, files over 5MB, or non-200 HTTP responses.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Key-value store for application settings.
|
||||
|
||||
### `GET /api/settings/:key`
|
||||
|
||||
Get a setting by key. Public.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "key": "collectionName", "value": "My Bikepacking Gear" }
|
||||
```
|
||||
|
||||
Returns `404` if the key does not exist.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/settings/:key`
|
||||
|
||||
Create or update a setting. Auth required.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "value": "My Bikepacking Gear" }
|
||||
```
|
||||
|
||||
**Response:** updated setting object.
|
||||
|
||||
---
|
||||
|
||||
## Totals
|
||||
|
||||
### `GET /api/totals`
|
||||
|
||||
Global and per-category weight and cost totals. Computed from current item data. Public.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"global": {
|
||||
"totalWeightGrams": 12500,
|
||||
"totalPriceCents": 450000,
|
||||
"itemCount": 34
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"categoryId": 2,
|
||||
"name": "Bags",
|
||||
"icon": "backpack",
|
||||
"totalWeightGrams": 1200,
|
||||
"totalPriceCents": 78000,
|
||||
"itemCount": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
Authentication endpoints are documented in [authentication.md](authentication.md).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|-------------------------|----------|---------------------------|
|
||||
| GET | `/api/auth/me` | public | Current session state |
|
||||
| POST | `/api/auth/setup` | public | Create first admin user |
|
||||
| POST | `/api/auth/login` | public | Log in, receive cookie |
|
||||
| POST | `/api/auth/logout` | public | Clear session |
|
||||
| PUT | `/api/auth/password` | session | Change password |
|
||||
| GET | `/api/auth/keys` | required | List API keys |
|
||||
| POST | `/api/auth/keys` | required | Create API key |
|
||||
| DELETE | `/api/auth/keys/:id` | required | Revoke API key |
|
||||
281
docs/authentication.md
Normal file
281
docs/authentication.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Authentication
|
||||
|
||||
GearBox uses a public-read, authenticated-write model. All GET endpoints are publicly accessible with no credentials required. Any request that modifies data (POST, PUT, PATCH, DELETE) requires authentication.
|
||||
|
||||
This is a single-user app. There is exactly one admin account.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [First-Time Setup](#first-time-setup)
|
||||
- [Web UI Authentication](#web-ui-authentication)
|
||||
- [API Keys](#api-keys)
|
||||
- [Auth Middleware Behavior](#auth-middleware-behavior)
|
||||
- [Auth API Reference](#auth-api-reference)
|
||||
- [Frontend Behavior](#frontend-behavior)
|
||||
|
||||
---
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
When no users exist, all write endpoints return `403` with `{ "error": "setup_required" }`. To create the admin account, visit `/login` in the browser and complete the setup form, or call the setup endpoint directly:
|
||||
|
||||
```http
|
||||
POST /api/auth/setup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "yourpassword"
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
- `username`: any non-empty string
|
||||
- `password`: minimum 6 characters
|
||||
|
||||
This endpoint only works when no users exist. Subsequent calls return `403 { "error": "Setup already completed" }`.
|
||||
|
||||
On success, a session cookie is set and `201` is returned:
|
||||
|
||||
```json
|
||||
{ "username": "admin" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web UI Authentication
|
||||
|
||||
Sessions use an `httpOnly` cookie named `gearbox_session`.
|
||||
|
||||
| Property | Value |
|
||||
|------------|--------------------|
|
||||
| Cookie name | `gearbox_session` |
|
||||
| httpOnly | true |
|
||||
| sameSite | Lax |
|
||||
| path | / |
|
||||
| Max age | 30 days |
|
||||
|
||||
The session expiry is **automatically refreshed** on each authenticated request. As long as the app is used at least once every 30 days, the session stays active.
|
||||
|
||||
Passwords are hashed with **argon2** via `Bun.password`.
|
||||
|
||||
### Changing Your Password
|
||||
|
||||
Requires an active session cookie.
|
||||
|
||||
```http
|
||||
PUT /api/auth/password
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"currentPassword": "oldpassword",
|
||||
"newPassword": "newpassword"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Keys
|
||||
|
||||
API keys are intended for programmatic access (scripts, MCP clients, integrations). They are managed under **Settings > API Keys** in the web UI, or via the API endpoints listed below.
|
||||
|
||||
### Key behavior
|
||||
|
||||
- Keys are shown **once** at creation time. Store them securely.
|
||||
- Keys are stored as an argon2 hash. Only the 8-character prefix is stored in plaintext for display and lookup purposes.
|
||||
- Pass the key via the `X-API-Key` request header on any write request.
|
||||
|
||||
```http
|
||||
POST /api/items
|
||||
X-API-Key: gbk_a1b2c3d4...
|
||||
Content-Type: application/json
|
||||
|
||||
{ "name": "Revelate Tangle", "categoryId": 2 }
|
||||
```
|
||||
|
||||
If both a session cookie and an `X-API-Key` header are present, the API key is checked first.
|
||||
|
||||
---
|
||||
|
||||
## Auth Middleware Behavior
|
||||
|
||||
The middleware applied to `/api/*` (excluding `/api/auth/*`) follows these rules:
|
||||
|
||||
1. `GET` requests — always allowed, no auth check.
|
||||
2. No users exist — returns `403 { "error": "setup_required" }`.
|
||||
3. `X-API-Key` header present — verified against stored hashes; `401` on failure.
|
||||
4. `gearbox_session` cookie present — verified against sessions table; refreshed on success; `401` on failure.
|
||||
5. Neither credential present — returns `401 { "error": "Authentication required" }`.
|
||||
|
||||
The `/api/auth/*` routes handle their own auth logic and are excluded from the global middleware.
|
||||
|
||||
---
|
||||
|
||||
## Auth API Reference
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Returns the current session state. Always public.
|
||||
|
||||
**Response when logged in:**
|
||||
```json
|
||||
{
|
||||
"user": { "id": 1 },
|
||||
"setupRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response when logged out, setup complete:**
|
||||
```json
|
||||
{
|
||||
"user": null,
|
||||
"setupRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response when no users exist:**
|
||||
```json
|
||||
{
|
||||
"user": null,
|
||||
"setupRequired": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/setup`
|
||||
|
||||
Create the first admin account. Only works when no users exist.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "yourpassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{ "username": "admin" }
|
||||
```
|
||||
|
||||
Sets `gearbox_session` cookie.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Log in with username and password.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "yourpassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200`
|
||||
```json
|
||||
{ "username": "admin" }
|
||||
```
|
||||
|
||||
Sets `gearbox_session` cookie. Returns `401` on invalid credentials.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Clear the current session. No request body needed.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Clears the `gearbox_session` cookie and deletes the session from the database.
|
||||
|
||||
---
|
||||
|
||||
### `PUT /api/auth/password`
|
||||
|
||||
Change the admin password. Requires an active session cookie (not API key).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"currentPassword": "oldpassword",
|
||||
"newPassword": "newpassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Returns `401` if `currentPassword` is incorrect.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/keys`
|
||||
|
||||
List all API keys. Returns name, prefix, and creation timestamp — never the full key.
|
||||
|
||||
Requires auth.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Claude Code",
|
||||
"prefix": "gbk_a1b2",
|
||||
"createdAt": "2025-03-01T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/keys`
|
||||
|
||||
Create a new API key. The full key is returned **once** and cannot be retrieved again.
|
||||
|
||||
Requires auth.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "name": "Claude Code" }
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Claude Code",
|
||||
"key": "gbk_a1b2c3d4e5f6g7h8i9j0...",
|
||||
"prefix": "gbk_a1b2"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/auth/keys/:id`
|
||||
|
||||
Revoke an API key by ID. Requires auth.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Behavior
|
||||
|
||||
- A login button is shown in the top-right corner of the UI (Gitea-style).
|
||||
- The floating action button (FAB) for adding items is hidden when not logged in.
|
||||
- Edit and delete actions on items, threads, and setups require auth. Unauthenticated users see read-only views.
|
||||
- When `setupRequired` is true, the UI redirects to the setup flow.
|
||||
253
docs/mcp-server.md
Normal file
253
docs/mcp-server.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# MCP Server
|
||||
|
||||
GearBox includes a built-in MCP (Model Context Protocol) server that exposes your gear collection to AI assistants. It runs inside the Hono process — no separate service is needed.
|
||||
|
||||
The MCP server implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification) from `@modelcontextprotocol/sdk` and is mounted at `/mcp`.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Enabling and Disabling](#enabling-and-disabling)
|
||||
- [Authentication](#authentication)
|
||||
- [Connecting an AI Client](#connecting-an-ai-client)
|
||||
- [Claude Code](#claude-code)
|
||||
- [Claude Desktop](#claude-desktop)
|
||||
- [Tools](#tools)
|
||||
- [Resources](#resources)
|
||||
- [Recommended Workflow](#recommended-workflow)
|
||||
- [Example Session](#example-session)
|
||||
- [Implementation Structure](#implementation-structure)
|
||||
|
||||
---
|
||||
|
||||
## Enabling and Disabling
|
||||
|
||||
The MCP server is **enabled by default**. To disable it, set the environment variable:
|
||||
|
||||
```bash
|
||||
GEARBOX_MCP=false bun run dev:server
|
||||
```
|
||||
|
||||
Or in your environment file:
|
||||
|
||||
```bash
|
||||
GEARBOX_MCP=false
|
||||
```
|
||||
|
||||
When disabled, the `/mcp` route is not registered and any requests to it return 404.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The MCP server requires an API key when the app has been set up (i.e., at least one user exists). Pass the key via the `X-API-Key` header in your MCP client configuration.
|
||||
|
||||
To create an API key:
|
||||
1. Log in to the web UI.
|
||||
2. Go to **Settings > API Keys**.
|
||||
3. Click **New Key**, give it a name, and copy the full key shown.
|
||||
|
||||
The full key is only shown once. Store it in your client configuration immediately.
|
||||
|
||||
See [authentication.md](authentication.md) for full API key documentation.
|
||||
|
||||
---
|
||||
|
||||
## Connecting an AI Client
|
||||
|
||||
### Claude Code
|
||||
|
||||
Add the MCP server to `.claude/settings.json` in your project or globally at `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add the server to `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gearbox": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
On macOS, the config file is at `~/Library/Application Support/Claude/claude_desktop_config.json`.
|
||||
|
||||
After updating the config, restart Claude Desktop (or reload the window in Claude Code) for the server to connect.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
The MCP server exposes 18 tools across five categories.
|
||||
|
||||
### Items
|
||||
|
||||
| Tool | Description |
|
||||
|----------------|------------------------------------------------------------------------------------------------------------|
|
||||
| `list_items` | List all items in the gear collection, optionally filtered by `categoryId`. |
|
||||
| `get_item` | Get a single item by ID with all details. |
|
||||
| `create_item` | Add a new item directly to the collection. Use for items already decided on; use `create_thread` for research. |
|
||||
| `update_item` | Update an existing item's fields. |
|
||||
| `delete_item` | Delete an item from the collection by ID. |
|
||||
|
||||
### Categories
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|--------------------------------------|
|
||||
| `list_categories` | List all gear categories. |
|
||||
| `create_category` | Create a new gear category. |
|
||||
|
||||
### Threads
|
||||
|
||||
| Tool | Description |
|
||||
|------------------|------------------------------------------------------------------------------------------------------|
|
||||
| `list_threads` | List research threads. Pass `includeResolved: true` to include resolved threads. |
|
||||
| `get_thread` | Get a thread with all its candidates for detailed comparison. |
|
||||
| `create_thread` | Start a new research thread for a gear slot. Preferred entry point for gear research. |
|
||||
| `resolve_thread` | Resolve a thread by picking the winning candidate. Automatically creates a new item in the collection. |
|
||||
|
||||
### Candidates
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|-----------------------------------------------------------------------|
|
||||
| `add_candidate` | Add a candidate option to a research thread for comparison. |
|
||||
| `update_candidate` | Update a candidate's details (name, price, pros, cons, status, etc.).|
|
||||
| `remove_candidate` | Remove a candidate from a research thread. |
|
||||
|
||||
### Setups
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------|-------------------------------------------------------------------------------------------------------|
|
||||
| `list_setups` | List all gear setups with item counts and weight/cost totals. |
|
||||
| `get_setup` | Get a setup with all its items and details. |
|
||||
| `create_setup` | Create a new gear setup (e.g., "Bikepacking weekend"). |
|
||||
| `update_setup` | Update a setup's name and/or replace its item list by passing `itemIds`. |
|
||||
|
||||
### Images
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------------|--------------------------------------------------------------------------------------------------|
|
||||
| `upload_image_from_url` | Fetch an image from a URL and save it locally. Returns a `filename` to use with items or candidates. |
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### `gearbox://collection/summary`
|
||||
|
||||
Returns a JSON overview of the entire gear collection, including:
|
||||
- Total item count, weight, and cost
|
||||
- Per-category breakdowns
|
||||
- Active research thread count
|
||||
|
||||
Read this resource first to orient yourself before running queries or making changes.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Workflow
|
||||
|
||||
Tool descriptions are written to guide AI assistants toward the research thread workflow rather than creating items directly. The intended flow for evaluating a new piece of gear is:
|
||||
|
||||
1. **`create_thread`** — open a research thread for the gear slot (e.g., "Handlebar bag")
|
||||
2. **`add_candidate`** — add options with prices, weights, pros, and cons
|
||||
3. **`get_thread`** — review all candidates side by side
|
||||
4. **`resolve_thread`** — pick the winner; it becomes a new item in the collection automatically
|
||||
|
||||
Use `create_item` directly only when you already know exactly what you're adding and no research is needed.
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
The following illustrates a typical assistant interaction via the MCP server.
|
||||
|
||||
**User:** "I need a new handlebar bag. Can you research some options and add them?"
|
||||
|
||||
**Assistant actions:**
|
||||
|
||||
```
|
||||
1. list_categories
|
||||
-> finds "Bags" category (id: 2)
|
||||
|
||||
2. create_thread { name: "Handlebar bag", categoryId: 2 }
|
||||
-> thread id: 7
|
||||
|
||||
3. upload_image_from_url { url: "https://revelatedesigns.com/sweetroll.jpg" }
|
||||
-> filename: "1710000000000-uuid.jpg"
|
||||
|
||||
4. add_candidate {
|
||||
threadId: 7,
|
||||
name: "Revelate Sweetroll",
|
||||
categoryId: 2,
|
||||
weightGrams: 290,
|
||||
priceCents: 13500,
|
||||
productUrl: "https://revelatedesigns.com/...",
|
||||
imageFilename: "1710000000000-uuid.jpg",
|
||||
pros: "Waterproof, 16L, fits most drop bars",
|
||||
cons: "Expensive, bulky profile"
|
||||
}
|
||||
|
||||
5. add_candidate {
|
||||
threadId: 7,
|
||||
name: "Apidura Backcountry Handlebar Pack",
|
||||
categoryId: 2,
|
||||
weightGrams: 230,
|
||||
priceCents: 11000,
|
||||
pros: "Lighter, packable, good attachment system",
|
||||
cons: "Smaller capacity"
|
||||
}
|
||||
|
||||
6. add_candidate { ... }
|
||||
|
||||
7. get_thread { id: 7 }
|
||||
-> presents comparison to user
|
||||
|
||||
8. resolve_thread { threadId: 7, candidateId: 14 }
|
||||
-> Revelate Sweetroll added to collection as item id: 42
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Structure
|
||||
|
||||
The MCP server source lives under `src/server/mcp/`:
|
||||
|
||||
```
|
||||
src/server/mcp/
|
||||
index.ts # Server factory, transport handling, auth middleware, Hono route registration
|
||||
tools/
|
||||
items.ts # list_items, get_item, create_item, update_item, delete_item
|
||||
categories.ts # list_categories, create_category
|
||||
threads.ts # list_threads, get_thread, create_thread, resolve_thread,
|
||||
# add_candidate, update_candidate, remove_candidate
|
||||
setups.ts # list_setups, get_setup, create_setup, update_setup
|
||||
images.ts # upload_image_from_url
|
||||
resources/
|
||||
collection.ts # gearbox://collection/summary resource handler
|
||||
```
|
||||
|
||||
Each tool file exports a `*ToolDefinitions` array (name, description, inputSchema) and a `register*Tools(db)` factory function that returns handler implementations. The server in `index.ts` iterates over definitions and registers each handler with the MCP `McpServer` instance.
|
||||
|
||||
Session management uses the `WebStandardStreamableHTTPServerTransport` with UUID-based session IDs tracked in an in-memory `Map`. Sessions are cleaned up when the transport closes.
|
||||
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
@@ -0,0 +1,661 @@
|
||||
# Codebase Improvements Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Harden the server (explicit DB context, param validation, error handling, rate limiting), add client error boundaries, split the oversized collection route into focused components, and fix stale docs.
|
||||
|
||||
**Architecture:** Server changes are middleware-level (DB context, error handler, rate limiter) plus a small utility for param parsing. Client changes are a TanStack Router error boundary on the root route and extracting three tab components from the 634-line collection route. Docs change is a one-line fix in PROJECT.md.
|
||||
|
||||
**Tech Stack:** Hono middleware, TanStack Router errorComponent, React, TypeScript
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Explicit DB Context Middleware
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/index.ts:1-59`
|
||||
- Modify: `src/server/routes/settings.ts:3,12` (remove prodDb fallback)
|
||||
|
||||
- [ ] **Step 1: Add DB import and middleware to server index**
|
||||
|
||||
In `src/server/index.ts`, add the import for the production database at the top, alongside existing imports:
|
||||
|
||||
```ts
|
||||
import { db as prodDb } from "../db/index.ts";
|
||||
```
|
||||
|
||||
Then add a middleware **before** the auth middleware (before line 26) that sets the DB on every API request:
|
||||
|
||||
```ts
|
||||
// Inject production database into request context
|
||||
app.use("/api/*", async (c, next) => {
|
||||
c.set("db", prodDb);
|
||||
return next();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix auth middleware comment**
|
||||
|
||||
In the same file, update the comment on the auth middleware from:
|
||||
|
||||
```ts
|
||||
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove prodDb fallback from settings route**
|
||||
|
||||
In `src/server/routes/settings.ts`, remove the `prodDb` import and fallback. Change:
|
||||
|
||||
```ts
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
```
|
||||
|
||||
Remove this import entirely.
|
||||
|
||||
Change both occurrences of:
|
||||
```ts
|
||||
const database = c.get("db") ?? prodDb;
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
const database = c.get("db");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass. Tests already set `c.set("db", testDb)` so this change doesn't affect them.
|
||||
|
||||
- [ ] **Step 5: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/index.ts src/server/routes/settings.ts
|
||||
git commit -m "fix: add explicit DB context middleware for all API routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route Parameter Validation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/lib/params.ts`
|
||||
- Modify: `src/server/routes/items.ts`
|
||||
- Modify: `src/server/routes/categories.ts`
|
||||
- Modify: `src/server/routes/threads.ts`
|
||||
- Modify: `src/server/routes/setups.ts`
|
||||
- Modify: `src/server/routes/auth.ts:187-189`
|
||||
|
||||
- [ ] **Step 1: Create parseId helper**
|
||||
|
||||
Create `src/server/lib/params.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Parse a route parameter as a positive integer ID.
|
||||
* Returns the number if valid, or null if the string is not a positive integer.
|
||||
*/
|
||||
export function parseId(raw: string): number | null {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return null;
|
||||
return id;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update items routes**
|
||||
|
||||
In `src/server/routes/items.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace all `Number(c.req.param("id"))` patterns. For each route that uses an ID param, add validation. Example for `GET /:id`:
|
||||
|
||||
```ts
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const item = getItemById(db, id);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(item);
|
||||
});
|
||||
```
|
||||
|
||||
Apply the same pattern to `PUT /:id` and `DELETE /:id`. In each case, add `const id = parseId(...)` + the null check returning 400 right after.
|
||||
|
||||
- [ ] **Step 3: Update categories routes**
|
||||
|
||||
In `src/server/routes/categories.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace `Number(c.req.param("id"))` with `parseId(c.req.param("id"))` in `PUT /:id` and `DELETE /:id`, adding the null check:
|
||||
|
||||
```ts
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update threads routes**
|
||||
|
||||
In `src/server/routes/threads.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace all `Number(c.req.param(...))` calls. There are 8 occurrences across these handlers:
|
||||
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||
- `PUT /:id` — same
|
||||
- `DELETE /:id` — same
|
||||
- `POST /:id/candidates` — `const threadId = parseId(c.req.param("id"))`
|
||||
- `PUT /:threadId/candidates/:candidateId` — `const candidateId = parseId(c.req.param("candidateId"))`
|
||||
- `DELETE /:threadId/candidates/:candidateId` — same
|
||||
- `PATCH /:id/candidates/reorder` — `const threadId = parseId(c.req.param("id"))`
|
||||
- `POST /:id/resolve` — `const threadId = parseId(c.req.param("id"))`
|
||||
|
||||
For each, add the null check returning 400 with a descriptive message like `"Invalid thread ID"` or `"Invalid candidate ID"`.
|
||||
|
||||
- [ ] **Step 5: Update setups routes**
|
||||
|
||||
In `src/server/routes/setups.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Replace all `Number(c.req.param(...))` calls. There are 6 occurrences:
|
||||
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||
- `PUT /:id` — same
|
||||
- `DELETE /:id` — same
|
||||
- `PUT /:id/items` — same
|
||||
- `PATCH /:id/items/:itemId/classification` — both `setupId` and `itemId`
|
||||
- `DELETE /:id/items/:itemId` — both `setupId` and `itemId`
|
||||
|
||||
For the classification and item removal routes with two params:
|
||||
|
||||
```ts
|
||||
const setupId = parseId(c.req.param("id"));
|
||||
const itemId = parseId(c.req.param("itemId"));
|
||||
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update auth routes**
|
||||
|
||||
In `src/server/routes/auth.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { parseId } from "../lib/params.ts";
|
||||
```
|
||||
|
||||
Update `DELETE /keys/:id` (line 187-189):
|
||||
|
||||
```ts
|
||||
app.delete("/keys/:id", requireAuth, (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid key ID" }, 400);
|
||||
deleteApiKey(db, id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass. Existing tests use valid integer IDs so no breakage.
|
||||
|
||||
- [ ] **Step 8: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/lib/params.ts src/server/routes/items.ts src/server/routes/categories.ts src/server/routes/threads.ts src/server/routes/setups.ts src/server/routes/auth.ts
|
||||
git commit -m "fix: validate route ID parameters, return 400 for invalid IDs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Centralized Error Handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/index.ts`
|
||||
|
||||
- [ ] **Step 1: Add onError handler**
|
||||
|
||||
In `src/server/index.ts`, add the error handler after the app is created (after `const app = new Hono()`) but before any routes:
|
||||
|
||||
```ts
|
||||
// Centralized error handler
|
||||
app.onError((err, c) => {
|
||||
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||
const message =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "Internal server error"
|
||||
: err.message || "Internal server error";
|
||||
return c.json({ error: message }, 500);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/index.ts
|
||||
git commit -m "fix: add centralized error handler for unhandled exceptions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rate Limiting on Auth Endpoints
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/middleware/rateLimit.ts`
|
||||
- Modify: `src/server/routes/auth.ts`
|
||||
|
||||
- [ ] **Step 1: Create rate limiter middleware**
|
||||
|
||||
Create `src/server/middleware/rateLimit.ts`:
|
||||
|
||||
```ts
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
function getClientIp(c: Context): string {
|
||||
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now >= entry.resetAt) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function rateLimit(c: Context, next: Next) {
|
||||
cleanup();
|
||||
|
||||
const ip = getClientIp(c);
|
||||
const key = `${ip}:${c.req.path}`;
|
||||
const now = Date.now();
|
||||
|
||||
const entry = store.get(key);
|
||||
|
||||
if (!entry || now >= entry.resetAt) {
|
||||
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return next();
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_ATTEMPTS) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return next();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apply rate limiter to auth routes**
|
||||
|
||||
In `src/server/routes/auth.ts`, add the import:
|
||||
|
||||
```ts
|
||||
import { rateLimit } from "../middleware/rateLimit.ts";
|
||||
```
|
||||
|
||||
Update the `POST /setup` handler to include the rate limiter:
|
||||
|
||||
```ts
|
||||
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
|
||||
```
|
||||
|
||||
Update the `POST /login` handler to include the rate limiter:
|
||||
|
||||
```ts
|
||||
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass. Auth tests make fewer than 5 requests per endpoint so rate limiting won't trigger.
|
||||
|
||||
- [ ] **Step 4: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/middleware/rateLimit.ts src/server/routes/auth.ts
|
||||
git commit -m "feat: add rate limiting on login and setup endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Client Error Boundary
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/client/routes/__root.tsx`
|
||||
|
||||
- [ ] **Step 1: Add error boundary component and wire it up**
|
||||
|
||||
In `src/client/routes/__root.tsx`, add the import for `useRouter` at the top (add to existing import from `@tanstack/react-router`):
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRootRoute,
|
||||
Outlet,
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
type ErrorComponentProps,
|
||||
} from "@tanstack/react-router";
|
||||
```
|
||||
|
||||
Add the `errorComponent` to the route definition:
|
||||
|
||||
```ts
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
errorComponent: RootErrorBoundary,
|
||||
});
|
||||
```
|
||||
|
||||
Add the `RootErrorBoundary` function before `RootLayout`:
|
||||
|
||||
```tsx
|
||||
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto text-center px-4">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-6 h-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset();
|
||||
router.invalidate();
|
||||
}}
|
||||
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/routes/__root.tsx
|
||||
git commit -m "feat: add error boundary to root route for crash resilience"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Split Collection Route into Tab Components
|
||||
|
||||
**Files:**
|
||||
- Create: `src/client/components/CollectionView.tsx`
|
||||
- Create: `src/client/components/PlanningView.tsx`
|
||||
- Create: `src/client/components/SetupsView.tsx`
|
||||
- Modify: `src/client/routes/collection/index.tsx`
|
||||
|
||||
- [ ] **Step 1: Create CollectionView component**
|
||||
|
||||
Create `src/client/components/CollectionView.tsx` with the `CollectionView` function extracted from `collection/index.tsx` (lines 72-334). The component needs these imports:
|
||||
|
||||
```tsx
|
||||
import { useMemo, useState } from "react";
|
||||
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||
import { CategoryHeader } from "./CategoryHeader";
|
||||
import { ItemCard } from "./ItemCard";
|
||||
import { useCategories } from "../hooks/useCategories";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useItems } from "../hooks/useItems";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export function CollectionView() {
|
||||
// ... exact same function body as lines 73-334 of collection/index.tsx
|
||||
}
|
||||
```
|
||||
|
||||
Copy the entire `CollectionView` function body as-is. No logic changes.
|
||||
|
||||
- [ ] **Step 2: Create PlanningView component**
|
||||
|
||||
Create `src/client/components/PlanningView.tsx` with the `PlanningView` function extracted from `collection/index.tsx` (lines 337-523):
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||
import { CreateThreadModal } from "./CreateThreadModal";
|
||||
import { ThreadCard } from "./ThreadCard";
|
||||
import { useCategories } from "../hooks/useCategories";
|
||||
import { useThreads } from "../hooks/useThreads";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export function PlanningView() {
|
||||
// ... exact same function body as lines 338-523 of collection/index.tsx
|
||||
}
|
||||
```
|
||||
|
||||
Copy the entire `PlanningView` function body as-is. No logic changes.
|
||||
|
||||
- [ ] **Step 3: Create SetupsView component**
|
||||
|
||||
Create `src/client/components/SetupsView.tsx` with the `SetupsView` function extracted from `collection/index.tsx` (lines 526-633):
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { SetupCard } from "./SetupCard";
|
||||
import { useCreateSetup, useSetups } from "../hooks/useSetups";
|
||||
|
||||
export function SetupsView() {
|
||||
// ... exact same function body as lines 527-633 of collection/index.tsx
|
||||
}
|
||||
```
|
||||
|
||||
Copy the entire `SetupsView` function body as-is. No logic changes.
|
||||
|
||||
- [ ] **Step 4: Update collection/index.tsx**
|
||||
|
||||
Replace the entire file content. Keep only the route definition, tab switching logic, animation constants, and imports from the new components:
|
||||
|
||||
```tsx
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { z } from "zod";
|
||||
import { CollectionView } from "../../components/CollectionView";
|
||||
import { PlanningView } from "../../components/PlanningView";
|
||||
import { SetupsView } from "../../components/SetupsView";
|
||||
|
||||
const searchSchema = z.object({
|
||||
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/collection/")({
|
||||
validateSearch: searchSchema,
|
||||
component: CollectionPage,
|
||||
});
|
||||
|
||||
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
||||
|
||||
const slideVariants = {
|
||||
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }),
|
||||
};
|
||||
|
||||
function CollectionPage() {
|
||||
const { tab } = Route.useSearch();
|
||||
const prevTab = useRef(tab);
|
||||
|
||||
const direction =
|
||||
TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
|
||||
prevTab.current = tab;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
||||
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||
<motion.div
|
||||
key={tab}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.12, ease: "easeInOut" }}
|
||||
>
|
||||
{tab === "gear" ? (
|
||||
<CollectionView />
|
||||
) : tab === "planning" ? (
|
||||
<PlanningView />
|
||||
) : (
|
||||
<SetupsView />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors. (Biome may flag import organization — fix if needed.)
|
||||
|
||||
- [ ] **Step 6: Run tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/client/components/CollectionView.tsx src/client/components/PlanningView.tsx src/client/components/SetupsView.tsx src/client/routes/collection/index.tsx
|
||||
git commit -m "refactor: extract tab views from collection route into separate components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `.planning/PROJECT.md:84`
|
||||
|
||||
- [ ] **Step 1: Update stale constraint**
|
||||
|
||||
In `.planning/PROJECT.md`, change line 84 from:
|
||||
|
||||
```
|
||||
- **Scope**: No auth, single user for v1
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```
|
||||
- **Scope**: Single user with cookie/API key auth
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .planning/PROJECT.md
|
||||
git commit -m "docs: update PROJECT.md constraints to reflect auth implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final Verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All 183 tests pass.
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Verify dev server starts**
|
||||
|
||||
Run: `bun run dev:server &` then `curl http://localhost:3000/api/health`
|
||||
Expected: `{"status":"ok"}`
|
||||
Then kill the background server.
|
||||
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
@@ -0,0 +1,934 @@
|
||||
# Testing Improvements Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add unit tests for new server code (parseId, rate limiter, param validation routes), set up Playwright E2E testing with a seeded database, and write E2E tests covering dashboard, collection, threads, auth, and error handling.
|
||||
|
||||
**Architecture:** Unit tests use existing Bun test runner + Hono `app.request()` pattern. E2E tests use Playwright against a real server with a pre-seeded SQLite database. A global-setup script creates the test DB using Drizzle migrations + direct inserts before Playwright runs.
|
||||
|
||||
**Tech Stack:** Bun test runner, Playwright (Chromium only), Drizzle ORM migrations, Hono
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Unit Tests for parseId
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/lib/params.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
Create `tests/lib/params.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseId } from "../../src/server/lib/params";
|
||||
|
||||
describe("parseId", () => {
|
||||
it("returns number for valid positive integers", () => {
|
||||
expect(parseId("1")).toBe(1);
|
||||
expect(parseId("42")).toBe(42);
|
||||
expect(parseId("999")).toBe(999);
|
||||
});
|
||||
|
||||
it("returns null for zero", () => {
|
||||
expect(parseId("0")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for negative numbers", () => {
|
||||
expect(parseId("-1")).toBeNull();
|
||||
expect(parseId("-100")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for decimals", () => {
|
||||
expect(parseId("1.5")).toBeNull();
|
||||
expect(parseId("3.14")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-numeric strings", () => {
|
||||
expect(parseId("abc")).toBeNull();
|
||||
expect(parseId("")).toBeNull();
|
||||
expect(parseId("hello")).toBeNull();
|
||||
expect(parseId("12abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for special values", () => {
|
||||
expect(parseId("NaN")).toBeNull();
|
||||
expect(parseId("Infinity")).toBeNull();
|
||||
expect(parseId("-Infinity")).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `bun test tests/lib/params.test.ts`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/lib/params.test.ts
|
||||
git commit -m "test: add unit tests for parseId helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Unit Tests for Rate Limiter
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/middleware/rateLimit.ts` (add test reset function)
|
||||
- Create: `tests/middleware/rateLimit.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add test reset function to rate limiter**
|
||||
|
||||
In `src/server/middleware/rateLimit.ts`, add at the end of the file:
|
||||
|
||||
```ts
|
||||
/** @internal — only for testing */
|
||||
export function _resetForTesting() {
|
||||
store.clear();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write tests**
|
||||
|
||||
Create `tests/middleware/rateLimit.test.ts`:
|
||||
|
||||
```ts
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { _resetForTesting, rateLimit } from "../../src/server/middleware/rateLimit";
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono();
|
||||
app.post("/login", rateLimit, (c) => c.json({ ok: true }));
|
||||
app.post("/setup", rateLimit, (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
|
||||
return app.request(path, {
|
||||
method: "POST",
|
||||
headers: { "x-forwarded-for": ip },
|
||||
});
|
||||
}
|
||||
|
||||
describe("rateLimit middleware", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
it("allows first request through", async () => {
|
||||
const app = createApp();
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows up to 5 requests", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 429 after 5 requests", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login");
|
||||
}
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(429);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Too many attempts. Try again later.");
|
||||
});
|
||||
|
||||
it("includes Retry-After header on 429", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login");
|
||||
}
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(429);
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
expect(retryAfter).toBeTruthy();
|
||||
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tracks different IPs independently", async () => {
|
||||
const app = createApp();
|
||||
// Fill up IP 1
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login", "10.0.0.1");
|
||||
}
|
||||
// IP 1 is blocked
|
||||
const blocked = await makeRequest(app, "/login", "10.0.0.1");
|
||||
expect(blocked.status).toBe(429);
|
||||
|
||||
// IP 2 still works
|
||||
const allowed = await makeRequest(app, "/login", "10.0.0.2");
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("tracks different paths independently", async () => {
|
||||
const app = createApp();
|
||||
// Fill up /login
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login");
|
||||
}
|
||||
const blockedLogin = await makeRequest(app, "/login");
|
||||
expect(blockedLogin.status).toBe(429);
|
||||
|
||||
// /setup still works
|
||||
const allowedSetup = await makeRequest(app, "/setup");
|
||||
expect(allowedSetup.status).toBe(200);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `bun test tests/middleware/rateLimit.test.ts`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All tests pass (previous 183 + new ones).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/middleware/rateLimit.ts tests/middleware/rateLimit.test.ts
|
||||
git commit -m "test: add unit tests for rate limiter middleware"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Route-Level Tests for Invalid ID Params
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/routes/params.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
Create `tests/routes/params.test.ts`:
|
||||
|
||||
```ts
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { categoryRoutes } from "../../src/server/routes/categories";
|
||||
import { itemRoutes } from "../../src/server/routes/items";
|
||||
import { setupRoutes } from "../../src/server/routes/setups";
|
||||
import { threadRoutes } from "../../src/server/routes/threads";
|
||||
import { createTestDb } from "../helpers/db";
|
||||
|
||||
function createTestApp() {
|
||||
const db = createTestDb();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("db", db);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/items", itemRoutes);
|
||||
app.route("/api/categories", categoryRoutes);
|
||||
app.route("/api/threads", threadRoutes);
|
||||
app.route("/api/setups", setupRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("Invalid ID parameter handling", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createTestApp();
|
||||
});
|
||||
|
||||
describe("items", () => {
|
||||
it("GET /api/items/abc returns 400", async () => {
|
||||
const res = await app.request("/api/items/abc");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("Invalid");
|
||||
});
|
||||
|
||||
it("GET /api/items/0 returns 400", async () => {
|
||||
const res = await app.request("/api/items/0");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /api/items/-1 returns 400", async () => {
|
||||
const res = await app.request("/api/items/-1");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("categories", () => {
|
||||
it("DELETE /api/categories/abc returns 400", async () => {
|
||||
const res = await app.request("/api/categories/abc", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("threads", () => {
|
||||
it("GET /api/threads/abc returns 400", async () => {
|
||||
const res = await app.request("/api/threads/abc");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /api/threads/1.5 returns 400", async () => {
|
||||
const res = await app.request("/api/threads/1.5");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setups", () => {
|
||||
it("GET /api/setups/abc returns 400", async () => {
|
||||
const res = await app.request("/api/setups/abc");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /api/setups/0 returns 400", async () => {
|
||||
const res = await app.request("/api/setups/0");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `bun test tests/routes/params.test.ts`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/routes/params.test.ts
|
||||
git commit -m "test: add route-level tests for invalid ID parameter handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Install Playwright and Create Config
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (add dep + scripts)
|
||||
- Create: `playwright.config.ts`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Install Playwright**
|
||||
|
||||
```bash
|
||||
bun add -d @playwright/test
|
||||
bunx playwright install chromium
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create playwright.config.ts**
|
||||
|
||||
Create `playwright.config.ts` at project root:
|
||||
|
||||
```ts
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 10000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add scripts to package.json**
|
||||
|
||||
Add these to the `"scripts"` section in `package.json`:
|
||||
|
||||
```json
|
||||
"test:e2e": "bunx playwright test",
|
||||
"test:e2e:ui": "bunx playwright test --ui"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update .gitignore**
|
||||
|
||||
Append to `.gitignore`:
|
||||
|
||||
```
|
||||
# Playwright
|
||||
e2e/test.db
|
||||
test-results/
|
||||
playwright-report/
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: Clean.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json bun.lock playwright.config.ts .gitignore
|
||||
git commit -m "chore: install Playwright and add E2E test configuration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: E2E Database Seed and Global Setup
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/seed.ts`
|
||||
- Create: `e2e/global-setup.ts`
|
||||
|
||||
- [ ] **Step 1: Create seed script**
|
||||
|
||||
Create `e2e/seed.ts`:
|
||||
|
||||
```ts
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import * as schema from "../src/db/schema";
|
||||
|
||||
const DB_PATH = "./e2e/test.db";
|
||||
|
||||
export async function seedTestDatabase() {
|
||||
// Remove old test DB if it exists
|
||||
try {
|
||||
await Bun.file(DB_PATH).exists() &&
|
||||
(await import("node:fs/promises")).then((fs) => fs.unlink(DB_PATH));
|
||||
} catch {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
|
||||
const sqlite = new Database(DB_PATH);
|
||||
sqlite.run("PRAGMA journal_mode = WAL");
|
||||
sqlite.run("PRAGMA foreign_keys = ON");
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
// Apply all migrations
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
|
||||
// ── Seed Categories ──
|
||||
const [uncategorized] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Uncategorized", icon: "package" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
const [shelter] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Shelter", icon: "tent" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
const [sleep] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Sleep System", icon: "moon" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
const [cook] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Cook Kit", icon: "flame" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
// ── Seed Items ──
|
||||
const tent = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Zpacks Duplex",
|
||||
weightGrams: 539,
|
||||
priceCents: 67900,
|
||||
categoryId: shelter.id,
|
||||
notes: "DCF shelter, 2-person",
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const tarp = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Borah Gear Tarp",
|
||||
weightGrams: 156,
|
||||
priceCents: 11000,
|
||||
categoryId: shelter.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const quilt = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Enlightened Equipment Enigma 20",
|
||||
weightGrams: 595,
|
||||
priceCents: 34000,
|
||||
categoryId: sleep.id,
|
||||
notes: "20F quilt",
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const pad = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Therm-a-Rest NeoAir XLite",
|
||||
weightGrams: 354,
|
||||
priceCents: 20999,
|
||||
categoryId: sleep.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const stove = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "BRS-3000T Stove",
|
||||
weightGrams: 25,
|
||||
priceCents: 2000,
|
||||
categoryId: cook.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const pot = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Toaks 750ml Pot",
|
||||
weightGrams: 103,
|
||||
priceCents: 3000,
|
||||
categoryId: cook.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
// ── Seed Active Thread with 3 Candidates ──
|
||||
const activeThread = db
|
||||
.insert(schema.threads)
|
||||
.values({
|
||||
name: "New Backpack",
|
||||
status: "active",
|
||||
categoryId: uncategorized.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: activeThread.id,
|
||||
name: "ULA Circuit",
|
||||
weightGrams: 1077,
|
||||
priceCents: 27500,
|
||||
categoryId: uncategorized.id,
|
||||
pros: "Great hip belt\nLarge capacity",
|
||||
cons: "Heavier than competitors",
|
||||
sortOrder: 1000,
|
||||
status: "researching",
|
||||
})
|
||||
.run();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: activeThread.id,
|
||||
name: "Gossamer Gear Mariposa",
|
||||
weightGrams: 737,
|
||||
priceCents: 28500,
|
||||
categoryId: uncategorized.id,
|
||||
pros: "Very lightweight\nGood ventilation",
|
||||
cons: "Smaller hip belt pockets",
|
||||
sortOrder: 2000,
|
||||
status: "researching",
|
||||
})
|
||||
.run();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: activeThread.id,
|
||||
name: "Granite Gear Crown2 38",
|
||||
weightGrams: 850,
|
||||
priceCents: 18000,
|
||||
categoryId: uncategorized.id,
|
||||
sortOrder: 3000,
|
||||
status: "ordered",
|
||||
})
|
||||
.run();
|
||||
|
||||
// ── Seed Resolved Thread ──
|
||||
const resolvedThread = db
|
||||
.insert(schema.threads)
|
||||
.values({
|
||||
name: "Camp Stove",
|
||||
status: "resolved",
|
||||
categoryId: cook.id,
|
||||
resolvedCandidateId: 1,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: resolvedThread.id,
|
||||
name: "BRS-3000T",
|
||||
weightGrams: 25,
|
||||
priceCents: 2000,
|
||||
categoryId: cook.id,
|
||||
sortOrder: 1000,
|
||||
status: "arrived",
|
||||
})
|
||||
.run();
|
||||
|
||||
// ── Seed Setup with Items ──
|
||||
const setup = db
|
||||
.insert(schema.setups)
|
||||
.values({ name: "Weekend Overnighter" })
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.setupItems)
|
||||
.values([
|
||||
{ setupId: setup.id, itemId: tent.id, classification: "base" },
|
||||
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
|
||||
{ setupId: setup.id, itemId: pad.id, classification: "base" },
|
||||
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
|
||||
])
|
||||
.run();
|
||||
|
||||
// ── Seed User ──
|
||||
const passwordHash = await Bun.password.hash("password123");
|
||||
db.insert(schema.users)
|
||||
.values({ username: "admin", passwordHash })
|
||||
.run();
|
||||
|
||||
// ── Seed Settings ──
|
||||
db.insert(schema.settings)
|
||||
.values([
|
||||
{ key: "weightUnit", value: "g" },
|
||||
{ key: "currency", value: "USD" },
|
||||
{ key: "onboardingComplete", value: "true" },
|
||||
])
|
||||
.run();
|
||||
|
||||
sqlite.close();
|
||||
console.log("E2E test database seeded at", DB_PATH);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create global-setup**
|
||||
|
||||
Create `e2e/global-setup.ts`:
|
||||
|
||||
```ts
|
||||
import { seedTestDatabase } from "./seed";
|
||||
|
||||
export default async function globalSetup() {
|
||||
await seedTestDatabase();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify seed works**
|
||||
|
||||
Run: `bun run e2e/global-setup.ts`
|
||||
Expected: Prints "E2E test database seeded at ./e2e/test.db" and the file exists.
|
||||
|
||||
Then clean up: `rm -f e2e/test.db`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/seed.ts e2e/global-setup.ts
|
||||
git commit -m "test: add E2E database seed and Playwright global setup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: E2E Tests — Dashboard and Collection
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/dashboard.spec.ts`
|
||||
- Create: `e2e/collection.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Create dashboard tests**
|
||||
|
||||
Create `e2e/dashboard.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Dashboard", () => {
|
||||
test("loads and shows summary cards", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("text=GearBox")).toBeVisible();
|
||||
// Should show item count (we seeded 6 items)
|
||||
await expect(page.locator("text=6")).toBeVisible();
|
||||
});
|
||||
|
||||
test("has navigation to collection", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Click on a dashboard card or link that goes to collection
|
||||
const collectionLink = page.locator('a[href*="collection"]').first();
|
||||
if (await collectionLink.isVisible()) {
|
||||
await collectionLink.click();
|
||||
await expect(page).toHaveURL(/collection/);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create collection tests**
|
||||
|
||||
Create `e2e/collection.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Collection", () => {
|
||||
test("gear tab shows items grouped by category", async ({ page }) => {
|
||||
await page.goto("/collection?tab=gear");
|
||||
// Should see seeded items
|
||||
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||
await expect(page.locator("text=BRS-3000T Stove")).toBeVisible();
|
||||
// Should see category headers
|
||||
await expect(page.locator("text=Shelter")).toBeVisible();
|
||||
await expect(page.locator("text=Cook Kit")).toBeVisible();
|
||||
});
|
||||
|
||||
test("search filters items by name", async ({ page }) => {
|
||||
await page.goto("/collection?tab=gear");
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill("Zpacks");
|
||||
// Should show matching item
|
||||
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||
// Should hide non-matching items
|
||||
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("tab switching works", async ({ page }) => {
|
||||
await page.goto("/collection?tab=gear");
|
||||
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||
|
||||
// Switch to planning tab
|
||||
await page.goto("/collection?tab=planning");
|
||||
await expect(page.locator("text=Planning Threads")).toBeVisible();
|
||||
await expect(page.locator("text=New Backpack")).toBeVisible();
|
||||
|
||||
// Switch to setups tab
|
||||
await page.goto("/collection?tab=setups");
|
||||
await expect(page.locator("text=Weekend Overnighter")).toBeVisible();
|
||||
});
|
||||
|
||||
test("category filter dropdown works", async ({ page }) => {
|
||||
await page.goto("/collection?tab=gear");
|
||||
// Open category filter
|
||||
const filterButton = page.locator("text=All categories");
|
||||
await filterButton.click();
|
||||
// Select "Shelter"
|
||||
await page.locator("li").filter({ hasText: "Shelter" }).click();
|
||||
// Should show only shelter items
|
||||
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run E2E tests**
|
||||
|
||||
Run: `bun run test:e2e`
|
||||
Expected: All tests pass. If any fail due to selector issues, adjust selectors based on actual DOM.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/dashboard.spec.ts e2e/collection.spec.ts
|
||||
git commit -m "test: add E2E tests for dashboard and collection views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: E2E Tests — Threads, Auth, Error Handling
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/threads.spec.ts`
|
||||
- Create: `e2e/auth.spec.ts`
|
||||
- Create: `e2e/error-handling.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Create threads tests**
|
||||
|
||||
Create `e2e/threads.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Threads", () => {
|
||||
test("thread detail page shows candidates", async ({ page }) => {
|
||||
// Navigate to the active thread
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.locator("text=New Backpack").click();
|
||||
// Should see candidates
|
||||
await expect(page.locator("text=ULA Circuit")).toBeVisible();
|
||||
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible();
|
||||
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible();
|
||||
});
|
||||
|
||||
test("rank badges are visible on candidates", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.locator("text=New Backpack").click();
|
||||
// Should see rank badges (gold, silver, bronze for top 3)
|
||||
// The rank badges use specific colors: #D4AF37 (gold), #C0C0C0 (silver), #CD7F32 (bronze)
|
||||
await expect(page.locator("text=#1").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("comparison view toggles on", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.locator("text=New Backpack").click();
|
||||
// Find and click the compare toggle
|
||||
const compareButton = page.locator("button", { hasText: /compare/i });
|
||||
if (await compareButton.isVisible()) {
|
||||
await compareButton.click();
|
||||
// Comparison table should appear with attribute rows
|
||||
await expect(page.locator("text=Weight")).toBeVisible();
|
||||
await expect(page.locator("text=Price")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolved thread shows winner", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
// Switch to resolved tab
|
||||
await page.locator("button", { hasText: "Resolved" }).click();
|
||||
await page.locator("text=Camp Stove").click();
|
||||
// Should indicate resolved state
|
||||
await expect(page.locator("text=BRS-3000T")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create auth tests**
|
||||
|
||||
Create `e2e/auth.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Auth", () => {
|
||||
test("login page renders", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.locator("text=Log in")).toBeVisible();
|
||||
});
|
||||
|
||||
test("login with valid credentials succeeds", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
|
||||
await page.locator('input[type="password"]').fill("password123");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
// Should redirect away from login
|
||||
await page.waitForURL((url) => !url.pathname.includes("/login"), {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("login with wrong password shows error", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
|
||||
await page.locator('input[type="password"]').fill("wrongpassword");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
// Should show error message
|
||||
await expect(page.locator("text=Invalid credentials").or(page.locator('[role="alert"]'))).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create error handling tests**
|
||||
|
||||
Create `e2e/error-handling.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Error handling", () => {
|
||||
test("non-existent thread shows not found or error", async ({ page }) => {
|
||||
await page.goto("/threads/99999");
|
||||
// Should not white-screen — should show some content
|
||||
const body = page.locator("body");
|
||||
await expect(body).not.toBeEmpty();
|
||||
// Either shows error boundary or "not found" text
|
||||
const hasContent = await page
|
||||
.locator("text=Something went wrong")
|
||||
.or(page.locator("text=not found"))
|
||||
.or(page.locator("text=Not Found"))
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// At minimum, the page should not be blank
|
||||
const bodyText = await body.textContent();
|
||||
expect(bodyText?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("non-existent setup shows not found or error", async ({ page }) => {
|
||||
await page.goto("/setups/99999");
|
||||
const body = page.locator("body");
|
||||
await expect(body).not.toBeEmpty();
|
||||
const bodyText = await body.textContent();
|
||||
expect(bodyText?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("app recovers from navigation errors", async ({ page }) => {
|
||||
// Navigate to a bad route, then back to a good one
|
||||
await page.goto("/threads/99999");
|
||||
await page.goto("/");
|
||||
// Dashboard should load fine
|
||||
await expect(page.locator("text=GearBox")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all E2E tests**
|
||||
|
||||
Run: `bun run test:e2e`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/threads.spec.ts e2e/auth.spec.ts e2e/error-handling.spec.ts
|
||||
git commit -m "test: add E2E tests for threads, auth, and error handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final Verification
|
||||
|
||||
- [ ] **Step 1: Run unit tests**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All tests pass (previous 183 + new parseId + rate limiter + param routes).
|
||||
|
||||
- [ ] **Step 2: Run E2E tests**
|
||||
|
||||
Run: `bun run test:e2e`
|
||||
Expected: All E2E tests pass.
|
||||
|
||||
- [ ] **Step 3: Run lint**
|
||||
|
||||
Run: `bun run lint`
|
||||
Expected: Clean.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Code Quality Improvements (Round 2) Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Scope:** Combined formatters hook, test helper schema generation, stale todo cleanup
|
||||
|
||||
## 1. useFormatters Combined Hook
|
||||
|
||||
**Problem:** 14 component files import the same 3-4 lines: `useWeightUnit`, `useCurrency`, `formatWeight`, `formatPrice`. This is repetitive boilerplate.
|
||||
|
||||
**Solution:** Create `src/client/hooks/useFormatters.ts` that returns pre-bound formatting functions:
|
||||
|
||||
```ts
|
||||
export function useFormatters() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
return {
|
||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||
price: (cents: number | null) => formatPrice(cents, currency),
|
||||
unit,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Consumer files to update (14):**
|
||||
- CollectionView.tsx
|
||||
- setups/$setupId.tsx
|
||||
- routes/index.tsx
|
||||
- WeightSummaryCard.tsx
|
||||
- TotalsBar.tsx
|
||||
- settings.tsx
|
||||
- ThreadCard.tsx
|
||||
- SetupCard.tsx
|
||||
- ItemPicker.tsx
|
||||
- ItemCard.tsx
|
||||
- ComparisonTable.tsx
|
||||
- CandidateCard.tsx
|
||||
- CandidateListItem.tsx
|
||||
- CategoryHeader.tsx
|
||||
|
||||
Each file replaces 3-4 imports + 2 hook calls with 1 import + 1 destructured hook call. Components that need raw `unit` or `currency` (e.g., WeightSummaryCard uses `unit` as a type, TotalsBar has a unit toggle) get them from the return object.
|
||||
|
||||
## 2. Test Helper Schema Generation
|
||||
|
||||
**Problem:** `tests/helpers/db.ts` has 120 lines of hand-written CREATE TABLE SQL that must manually mirror `src/db/schema.ts`. Any schema change requires updating both files — a known source of `SqliteError: no such column` failures.
|
||||
|
||||
**Solution:** Replace hand-written SQL with Drizzle's migration runner:
|
||||
|
||||
```ts
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
|
||||
export function createTestDb() {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.run("PRAGMA foreign_keys = ON");
|
||||
const db = drizzle(sqlite, { schema });
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
db.insert(schema.categories).values({ name: "Uncategorized", icon: "package" }).run();
|
||||
return db;
|
||||
}
|
||||
```
|
||||
|
||||
This reduces the file from ~128 lines to ~15 lines and eliminates all future manual sync.
|
||||
|
||||
## 3. Stale Todo Cleanup
|
||||
|
||||
**Problem:** Pending todo "Replace planning category filter select with icon-aware dropdown" from 2026-03-15 is already resolved — `PlanningView.tsx` uses `<CategoryFilterDropdown>` which renders Lucide icons.
|
||||
|
||||
**Solution:** Move the todo file from `pending/` to `done/`.
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. **useFormatters hook** — create hook + update all 14 consumer files
|
||||
2. **Test helper migration** — replace hand-written SQL with migrate()
|
||||
3. **Todo cleanup** — move stale todo to done
|
||||
@@ -0,0 +1,152 @@
|
||||
# Codebase Improvements Design
|
||||
|
||||
**Date:** 2026-04--03
|
||||
**Scope:** General code quality, error handling, resilience, and maintainability improvements
|
||||
|
||||
## 1. Server Hardening
|
||||
|
||||
### 1a. Explicit DB Context Middleware
|
||||
|
||||
**File:** `src/server/index.ts`
|
||||
|
||||
Add middleware that explicitly sets `c.set("db", prodDb)` for all API routes. Currently routes call `c.get("db")` but nothing sets it in production — services silently fall back to `prodDb` via default parameters. This makes production behavior match the test pattern.
|
||||
|
||||
```ts
|
||||
import { db as prodDb } from "../db/index.ts";
|
||||
|
||||
app.use("/api/*", async (c, next) => {
|
||||
c.set("db", prodDb);
|
||||
return next();
|
||||
});
|
||||
```
|
||||
|
||||
Place this **before** the auth middleware so `db` is available when auth checks run.
|
||||
|
||||
### 1b. Route Parameter Validation
|
||||
|
||||
**New file:** `src/server/lib/params.ts`
|
||||
|
||||
Create a helper that validates numeric route params:
|
||||
|
||||
```ts
|
||||
export function parseId(raw: string): number | null {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return null;
|
||||
return id;
|
||||
}
|
||||
```
|
||||
|
||||
Update all route files (`items.ts`, `threads.ts`, `categories.ts`, `setups.ts`) to replace `Number(c.req.param("id"))` with `parseId()`, returning 400 for invalid IDs.
|
||||
|
||||
### 1c. Centralized Error Handling
|
||||
|
||||
**File:** `src/server/index.ts`
|
||||
|
||||
Add Hono's `onError` handler:
|
||||
|
||||
```ts
|
||||
app.onError((err, c) => {
|
||||
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||
const status = err instanceof HTTPException ? err.status : 500;
|
||||
const message = process.env.NODE_ENV === "production"
|
||||
? "Internal server error"
|
||||
: err.message;
|
||||
return c.json({ error: message }, status);
|
||||
});
|
||||
```
|
||||
|
||||
### 1d. Auth Comment Fix
|
||||
|
||||
**File:** `src/server/index.ts`
|
||||
|
||||
Change comment from:
|
||||
```
|
||||
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||
```
|
||||
To:
|
||||
```
|
||||
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||
```
|
||||
|
||||
### 1e. Rate Limiting on Auth Endpoints
|
||||
|
||||
**New file:** `src/server/middleware/rateLimit.ts`
|
||||
|
||||
In-memory rate limiter using a `Map<string, { count: number; resetAt: number }>`:
|
||||
|
||||
- Tracks by IP (`c.req.header("x-forwarded-for") || "unknown"`)
|
||||
- 5 attempts per 15-minute window
|
||||
- Returns 429 with `{ error: "Too many attempts. Try again later." }` and `Retry-After` header
|
||||
- Stale entries cleaned on each check
|
||||
- Applied to `POST /api/auth/login` and `POST /api/auth/setup`
|
||||
|
||||
## 2. Client Resilience
|
||||
|
||||
### Error Boundary
|
||||
|
||||
**File:** `src/client/routes/__root.tsx`
|
||||
|
||||
Add `errorComponent` to the root route definition:
|
||||
|
||||
```ts
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
errorComponent: RootErrorBoundary,
|
||||
});
|
||||
```
|
||||
|
||||
`RootErrorBoundary` renders a centered error message with:
|
||||
- "Something went wrong" heading
|
||||
- Error message in dev mode
|
||||
- "Try again" button that calls `router.invalidate()` + `reset()`
|
||||
|
||||
Uses TanStack Router's `ErrorComponentProps` which provides `error` and `reset`.
|
||||
|
||||
## 3. Client Refactor
|
||||
|
||||
### Split collection/index.tsx
|
||||
|
||||
Extract the three tab-level functions into separate component files:
|
||||
|
||||
| Source function | New file | Approx lines |
|
||||
|----------------|----------|-------------|
|
||||
| `CollectionView()` | `src/client/components/CollectionView.tsx` | ~260 |
|
||||
| `PlanningView()` | `src/client/components/PlanningView.tsx` | ~190 |
|
||||
| `SetupsView()` | `src/client/components/SetupsView.tsx` | ~110 |
|
||||
|
||||
`collection/index.tsx` keeps:
|
||||
- Route definition with `searchSchema` and `validateSearch`
|
||||
- `CollectionPage` function (tab switcher + AnimatePresence)
|
||||
- `TAB_ORDER` and `slideVariants` constants
|
||||
- Imports from the three new component files
|
||||
|
||||
Each extracted component is a named export, self-contained with its own hooks and local state.
|
||||
|
||||
## 4. Docs Cleanup
|
||||
|
||||
### PROJECT.md
|
||||
|
||||
**File:** `.planning/PROJECT.md`
|
||||
|
||||
Update Constraints section line:
|
||||
```
|
||||
- **Scope**: No auth, single user for v1
|
||||
```
|
||||
To:
|
||||
```
|
||||
- **Scope**: Single user with cookie/API key auth
|
||||
```
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
Group into 3-4 commits by area:
|
||||
1. **Server hardening**: DB middleware, param validation, error handler, rate limiter, comment fix
|
||||
2. **Client resilience + refactor**: Error boundary, split collection route
|
||||
3. **Docs cleanup**: PROJECT.md update
|
||||
|
||||
## Testing
|
||||
|
||||
- All 183 existing tests must continue to pass
|
||||
- Rate limiter: manual verification (no automated test needed for in-memory rate limiting in a single-user app)
|
||||
- Error boundary: manual verification by triggering a render error
|
||||
- Param validation: existing route tests cover happy paths; invalid IDs are a new edge case but won't break existing tests
|
||||
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Testing Improvements Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Scope:** Unit tests for new server code + Playwright E2E test setup with seeded database
|
||||
|
||||
## Part 1: Unit/Integration Tests (Bun test runner)
|
||||
|
||||
### tests/lib/params.test.ts
|
||||
|
||||
Tests for `parseId` helper in `src/server/lib/params.ts`:
|
||||
- Valid positive integers (1, 42, 999) return the number
|
||||
- Zero returns null
|
||||
- Negative numbers (-1, -100) return null
|
||||
- Decimals (1.5, 3.14) return null
|
||||
- Non-numeric strings ("abc", "", "hello") return null
|
||||
- NaN-producing values return null
|
||||
|
||||
### tests/middleware/rateLimit.test.ts
|
||||
|
||||
Tests for rate limiter in `src/server/middleware/rateLimit.ts`:
|
||||
- First request passes through (200)
|
||||
- 5 requests succeed, 6th returns 429
|
||||
- 429 response includes `Retry-After` header
|
||||
- Different IPs tracked independently
|
||||
- After window expires, requests succeed again
|
||||
|
||||
Since the rate limiter uses a module-level `Map`, tests need to either:
|
||||
- Reset the store between tests (export a `resetStore` for testing), OR
|
||||
- Use unique paths/IPs per test to avoid interference
|
||||
|
||||
Recommended: export a `_resetForTesting()` function from rateLimit.ts that clears the store. Only used in tests.
|
||||
|
||||
### tests/routes/params.test.ts
|
||||
|
||||
Route-level integration tests verifying 400 responses for invalid IDs:
|
||||
- `GET /api/items/abc` → 400
|
||||
- `GET /api/items/-1` → 400
|
||||
- `GET /api/items/0` → 400
|
||||
- `DELETE /api/categories/notanumber` → 400
|
||||
- `GET /api/threads/abc` → 400
|
||||
- `GET /api/setups/abc` → 400
|
||||
|
||||
Uses existing test app pattern with in-memory DB.
|
||||
|
||||
## Part 2: Playwright E2E Setup
|
||||
|
||||
### Installation
|
||||
|
||||
- `bun add -d @playwright/test`
|
||||
- `bunx playwright install chromium` (only Chromium needed)
|
||||
|
||||
### Configuration: playwright.config.ts
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
webServer: {
|
||||
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
```
|
||||
|
||||
### Database Seeding: e2e/seed.ts
|
||||
|
||||
Script that creates `e2e/test.db` with:
|
||||
- Run Drizzle migrations against the file
|
||||
- Seed data:
|
||||
- 1 user (username: "admin", password: "password123")
|
||||
- 3 categories: Shelter, Sleep System, Cook Kit
|
||||
- 6 items across categories with realistic weights/prices
|
||||
- 1 active thread with 3 candidates (with pros/cons, sort_order)
|
||||
- 1 resolved thread
|
||||
- 1 setup with 4 items (mixed classifications)
|
||||
- Settings: weightUnit=g, currency=USD, onboardingComplete=true
|
||||
|
||||
Run before E2E tests via `e2e/global-setup.ts` (Playwright globalSetup).
|
||||
|
||||
### E2E Test Files
|
||||
|
||||
**e2e/dashboard.spec.ts**
|
||||
- Dashboard page loads
|
||||
- Summary cards show item count, weight, cost
|
||||
- Navigation links to collection work
|
||||
|
||||
**e2e/collection.spec.ts**
|
||||
- Gear tab renders items grouped by category
|
||||
- Search input filters items by name
|
||||
- Category filter dropdown works
|
||||
- Tab switching between gear/planning/setups
|
||||
|
||||
**e2e/threads.spec.ts**
|
||||
- Thread detail page loads with candidates
|
||||
- Comparison view toggle works (shows table)
|
||||
- Rank badges visible on candidates
|
||||
|
||||
**e2e/auth.spec.ts**
|
||||
- Login page renders
|
||||
- Login with valid credentials succeeds
|
||||
- Login with wrong password shows error
|
||||
- Rate limiting returns error after 5 attempts
|
||||
|
||||
**e2e/error-boundary.spec.ts**
|
||||
- App doesn't white-screen on unknown routes
|
||||
- Navigating to a non-existent thread/setup shows appropriate error
|
||||
|
||||
### Scripts
|
||||
|
||||
Add to package.json:
|
||||
- `"test:e2e": "bunx playwright test"`
|
||||
- `"test:e2e:ui": "bunx playwright test --ui"` (for debugging)
|
||||
|
||||
### Files to .gitignore
|
||||
|
||||
- `e2e/test.db`
|
||||
- `test-results/`
|
||||
- `playwright-report/`
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. Unit tests for parseId, rate limiter, route params
|
||||
2. Playwright setup (install, config, seed, global-setup)
|
||||
3. Playwright E2E test files
|
||||
@@ -0,0 +1,114 @@
|
||||
# v1.4 Collection Tools Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Milestone:** v1.4 Collection Tools
|
||||
**Scope:** Setup impact preview, item quantity, CSV import/export, item duplication
|
||||
|
||||
## Feature 1: Setup Impact Preview
|
||||
|
||||
Already fully designed in `.planning/phases/13-setup-impact-preview/`. Two plans exist:
|
||||
- **13-01**: Pure `computeImpactDeltas` function + `useImpactDeltas` hook + uiStore state (TDD)
|
||||
- **13-02**: `SetupImpactSelector` + `ImpactDeltaBadge` components wired into thread detail
|
||||
|
||||
Execute the existing plans as-is. No design changes needed.
|
||||
|
||||
## Feature 2: Item Quantity
|
||||
|
||||
### Schema
|
||||
|
||||
Add `quantity INTEGER NOT NULL DEFAULT 1` to `items` table via Drizzle migration.
|
||||
|
||||
```ts
|
||||
quantity: integer("quantity").notNull().default(1),
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
Add to `createItemSchema` in `src/shared/schemas.ts`:
|
||||
```ts
|
||||
quantity: z.number().int().positive().optional(),
|
||||
```
|
||||
|
||||
Flows to `updateItemSchema` via `.partial()` automatically.
|
||||
|
||||
### Service Layer
|
||||
|
||||
No special business logic — quantity is a stored field.
|
||||
|
||||
**Totals computation changes:**
|
||||
- `totals.service.ts`: `getCategoryTotals()` and `getGlobalTotals()` must multiply `weightGrams * quantity` and `priceCents * quantity` in their SQL SUM aggregations.
|
||||
- `setup.service.ts`: `getSetupWithItems()` and `getAllSetups()` — when computing setup totals, multiply item weight/price by the item's quantity.
|
||||
|
||||
### UI
|
||||
|
||||
- **ItemForm**: Number input for quantity (min=1), placed below price field. Defaults to 1.
|
||||
- **ItemCard**: Show "x2" badge next to item name when quantity > 1. No badge when quantity is 1.
|
||||
- **Totals**: Already computed server-side with the quantity multiplication. No client-side changes for totals.
|
||||
- **Setup weight/cost**: The item's quantity determines its weight/cost contribution when included in a setup (one `setup_items` row, but totals reflect quantity).
|
||||
|
||||
### Thread Resolution
|
||||
|
||||
When a thread is resolved and a candidate is copied to an item, the new item gets `quantity: 1` (default). No special handling needed.
|
||||
|
||||
## Feature 3: CSV Import/Export
|
||||
|
||||
### Export
|
||||
|
||||
**Endpoint:** `GET /api/items/export`
|
||||
- Returns CSV with headers: `name,quantity,weightGrams,priceCents,category,notes,productUrl`
|
||||
- `Content-Type: text/csv`
|
||||
- `Content-Disposition: attachment; filename="gearbox-export.csv"`
|
||||
- Weight in grams, price in cents (raw values, no formatting)
|
||||
- Category column contains category name (not ID)
|
||||
|
||||
**Service:** `exportItemsCsv(db)` returns a CSV string. Joins items with categories for name lookup.
|
||||
|
||||
### Import
|
||||
|
||||
**Endpoint:** `POST /api/items/import`
|
||||
- Accepts multipart form upload (CSV file)
|
||||
- Parses rows, validates required fields (name is required, others optional)
|
||||
- Category matching: looks up by name (case-insensitive). Creates new category if not found.
|
||||
- Quantity defaults to 1 if not present in CSV
|
||||
- Returns `{ imported: number, created_categories: string[], errors: string[] }`
|
||||
- Skips rows with errors, continues processing remaining rows
|
||||
|
||||
**Service:** `importItemsCsv(db, csvContent: string)` parses and inserts items.
|
||||
|
||||
### UI
|
||||
|
||||
Settings page gets an "Import/Export" section:
|
||||
- "Export CSV" button — triggers download via `GET /api/items/export`
|
||||
- "Import CSV" file input — accepts .csv files, shows count of parsed rows, confirm button to upload
|
||||
- Success/error feedback after import completes
|
||||
|
||||
## Feature 4: Item Duplication
|
||||
|
||||
### API
|
||||
|
||||
**Endpoint:** `POST /api/items/:id/duplicate`
|
||||
- Copies all fields from source item: name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, imageSourceUrl, quantity
|
||||
- Appends " (copy)" to the name
|
||||
- New `createdAt`/`updatedAt` timestamps
|
||||
- Returns the new item
|
||||
|
||||
**Service:** `duplicateItem(db, id)` — fetches source item, inserts copy, returns new item.
|
||||
|
||||
### UI
|
||||
|
||||
- Add "Duplicate" action to ItemCard (alongside existing edit/delete actions)
|
||||
- Duplicating opens the edit panel pre-filled with the new item so the user can rename or adjust
|
||||
|
||||
## Phase Ordering
|
||||
|
||||
1. **Item Quantity** — schema change first since CSV import/export and totals depend on it
|
||||
2. **Setup Impact Preview** — execute existing Phase 13 plans
|
||||
3. **Item Duplication** — small, self-contained
|
||||
4. **CSV Import/Export** — depends on quantity field existing in schema
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Quantity per setup (setup_items.quantity) — items table quantity is sufficient for v1.4
|
||||
- CSV export with formatted weights/prices — raw values are more portable
|
||||
- Image export/import via CSV — images are local files, not CSV-compatible
|
||||
- Bulk edit from CSV preview — import creates, doesn't update existing items
|
||||
2
drizzle/0006_hard_the_professor.sql
Normal file
2
drizzle/0006_hard_the_professor.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `items` ADD `image_source_url` text;--> statement-breakpoint
|
||||
ALTER TABLE `thread_candidates` ADD `image_source_url` text;
|
||||
23
drizzle/0007_icy_prodigy.sql
Normal file
23
drizzle/0007_icy_prodigy.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`key_hash` text NOT NULL,
|
||||
`key_prefix` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
1
drizzle/0008_loving_colossus.sql
Normal file
1
drizzle/0008_loving_colossus.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `items` ADD `quantity` integer DEFAULT 1 NOT NULL;
|
||||
519
drizzle/meta/0006_snapshot.json
Normal file
519
drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,519 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "e26d6e96-f2cd-4b76-ad4e-58da0af6f3c0",
|
||||
"prevId": "297e86db-c777-4432-950e-b0129dedb2dc",
|
||||
"tables": {
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'package'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"categories_name_unique": {
|
||||
"name": "categories_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"items": {
|
||||
"name": "items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_source_url": {
|
||||
"name": "image_source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"items_category_id_categories_id_fk": {
|
||||
"name": "items_category_id_categories_id_fk",
|
||||
"tableFrom": "items",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_items": {
|
||||
"name": "setup_items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"setup_id": {
|
||||
"name": "setup_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"classification": {
|
||||
"name": "classification",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'base'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"setup_items_setup_id_setups_id_fk": {
|
||||
"name": "setup_items_setup_id_setups_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "setups",
|
||||
"columnsFrom": [
|
||||
"setup_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"setup_items_item_id_items_id_fk": {
|
||||
"name": "setup_items_item_id_items_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setups": {
|
||||
"name": "setups",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"thread_candidates": {
|
||||
"name": "thread_candidates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_source_url": {
|
||||
"name": "image_source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'researching'"
|
||||
},
|
||||
"pros": {
|
||||
"name": "pros",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cons": {
|
||||
"name": "cons",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"thread_candidates_thread_id_threads_id_fk": {
|
||||
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"thread_candidates_category_id_categories_id_fk": {
|
||||
"name": "thread_candidates_category_id_categories_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"threads": {
|
||||
"name": "threads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"resolved_candidate_id": {
|
||||
"name": "resolved_candidate_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"threads_category_id_categories_id_fk": {
|
||||
"name": "threads_category_id_categories_id_fk",
|
||||
"tableFrom": "threads",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
655
drizzle/meta/0007_snapshot.json
Normal file
655
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,655 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
|
||||
"prevId": "e26d6e96-f2cd-4b76-ad4e-58da0af6f3c0",
|
||||
"tables": {
|
||||
"api_keys": {
|
||||
"name": "api_keys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'package'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"categories_name_unique": {
|
||||
"name": "categories_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"items": {
|
||||
"name": "items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_source_url": {
|
||||
"name": "image_source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"items_category_id_categories_id_fk": {
|
||||
"name": "items_category_id_categories_id_fk",
|
||||
"tableFrom": "items",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_items": {
|
||||
"name": "setup_items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"setup_id": {
|
||||
"name": "setup_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"classification": {
|
||||
"name": "classification",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'base'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"setup_items_setup_id_setups_id_fk": {
|
||||
"name": "setup_items_setup_id_setups_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "setups",
|
||||
"columnsFrom": [
|
||||
"setup_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"setup_items_item_id_items_id_fk": {
|
||||
"name": "setup_items_item_id_items_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setups": {
|
||||
"name": "setups",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"thread_candidates": {
|
||||
"name": "thread_candidates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_source_url": {
|
||||
"name": "image_source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'researching'"
|
||||
},
|
||||
"pros": {
|
||||
"name": "pros",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cons": {
|
||||
"name": "cons",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"thread_candidates_thread_id_threads_id_fk": {
|
||||
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"thread_candidates_category_id_categories_id_fk": {
|
||||
"name": "thread_candidates_category_id_categories_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"threads": {
|
||||
"name": "threads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"resolved_candidate_id": {
|
||||
"name": "resolved_candidate_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"threads_category_id_categories_id_fk": {
|
||||
"name": "threads_category_id_categories_id_fk",
|
||||
"tableFrom": "threads",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
663
drizzle/meta/0008_snapshot.json
Normal file
663
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,663 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ede9f482-7af0-42bc-9672-43f5fba289d0",
|
||||
"prevId": "738e67c5-ebad-46c1-9261-6ab60ec4bdb1",
|
||||
"tables": {
|
||||
"api_keys": {
|
||||
"name": "api_keys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'package'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"categories_name_unique": {
|
||||
"name": "categories_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"items": {
|
||||
"name": "items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_source_url": {
|
||||
"name": "image_source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"items_category_id_categories_id_fk": {
|
||||
"name": "items_category_id_categories_id_fk",
|
||||
"tableFrom": "items",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_items": {
|
||||
"name": "setup_items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"setup_id": {
|
||||
"name": "setup_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"classification": {
|
||||
"name": "classification",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'base'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"setup_items_setup_id_setups_id_fk": {
|
||||
"name": "setup_items_setup_id_setups_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "setups",
|
||||
"columnsFrom": [
|
||||
"setup_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"setup_items_item_id_items_id_fk": {
|
||||
"name": "setup_items_item_id_items_id_fk",
|
||||
"tableFrom": "setup_items",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setups": {
|
||||
"name": "setups",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"thread_candidates": {
|
||||
"name": "thread_candidates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight_grams": {
|
||||
"name": "weight_grams",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product_url": {
|
||||
"name": "product_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "image_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image_source_url": {
|
||||
"name": "image_source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'researching'"
|
||||
},
|
||||
"pros": {
|
||||
"name": "pros",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cons": {
|
||||
"name": "cons",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"thread_candidates_thread_id_threads_id_fk": {
|
||||
"name": "thread_candidates_thread_id_threads_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"thread_candidates_category_id_categories_id_fk": {
|
||||
"name": "thread_candidates_category_id_categories_id_fk",
|
||||
"tableFrom": "thread_candidates",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"threads": {
|
||||
"name": "threads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"resolved_candidate_id": {
|
||||
"name": "resolved_candidate_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"threads_category_id_categories_id_fk": {
|
||||
"name": "threads_category_id_categories_id_fk",
|
||||
"tableFrom": "threads",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,27 @@
|
||||
"when": 1773696058992,
|
||||
"tag": "0005_clear_micromax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1775214741464,
|
||||
"tag": "0006_hard_the_professor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1775215076284,
|
||||
"tag": "0007_icy_prodigy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1775232090363,
|
||||
"tag": "0008_loving_colossus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
47
e2e/auth.spec.ts
Normal file
47
e2e/auth.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("login page renders at /login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should show the Sign In heading
|
||||
await expect(page.getByRole("heading", { name: "Sign In" })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Should have username and password inputs
|
||||
await expect(page.locator("#username")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator("#password")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("login with valid credentials succeeds and redirects away from /login", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.locator("#username").fill("admin");
|
||||
await page.locator("#password").fill("password123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
// After successful login, should redirect to / (dashboard)
|
||||
await page.waitForURL("/", { timeout: 5000 });
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("login with wrong password shows error", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.locator("#username").fill("admin");
|
||||
await page.locator("#password").fill("wrongpassword");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
// Should stay on the login page and show an error message
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 5000 });
|
||||
// The error paragraph should be visible (login.tsx renders <p className="text-sm text-red-600">{error}</p>)
|
||||
await expect(page.locator(".text-red-600")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
89
e2e/collection.spec.ts
Normal file
89
e2e/collection.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Collection page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/collection");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test.describe("Gear tab", () => {
|
||||
test("shows seeded items", async ({ page }) => {
|
||||
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
|
||||
});
|
||||
|
||||
test("search filters items by name", async ({ page }) => {
|
||||
const searchInput = page.getByPlaceholder("Search items...");
|
||||
await searchInput.fill("Zpacks");
|
||||
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||
// Other items should not be visible
|
||||
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("clearing search restores all items", async ({ page }) => {
|
||||
const searchInput = page.getByPlaceholder("Search items...");
|
||||
await searchInput.fill("Zpacks");
|
||||
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||
// Clear the search
|
||||
await searchInput.clear();
|
||||
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
|
||||
});
|
||||
|
||||
test("category filter dropdown opens and lists categories", async ({
|
||||
page,
|
||||
}) => {
|
||||
const filterButton = page.getByRole("button", {
|
||||
name: /all categories/i,
|
||||
});
|
||||
await filterButton.click();
|
||||
|
||||
// Dropdown list (ul) contains the category options
|
||||
const dropdown = page.locator("ul");
|
||||
await expect(
|
||||
dropdown.getByRole("button", { name: "Shelter" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dropdown.getByRole("button", { name: "Cook Kit" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("category filter shows only items in selected category", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Open filter dropdown
|
||||
const filterButton = page.getByRole("button", {
|
||||
name: /all categories/i,
|
||||
});
|
||||
await filterButton.click();
|
||||
|
||||
// Select "Shelter" from the dropdown list
|
||||
const dropdown = page.locator("ul");
|
||||
await dropdown.getByRole("button", { name: "Shelter" }).click();
|
||||
|
||||
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||
// Items from other categories should not be visible
|
||||
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Tab switching", () => {
|
||||
test("navigates to planning tab", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Planning tab shows the active thread
|
||||
await expect(page.getByText("New Backpack")).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigates to setups tab", async ({ page }) => {
|
||||
await page.goto("/collection?tab=setups");
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Setups tab shows the seeded setup
|
||||
await expect(page.getByText("Weekend Overnighter")).toBeVisible();
|
||||
});
|
||||
|
||||
test("gear tab is default and shows items", async ({ page }) => {
|
||||
// Default tab (no ?tab param) shows gear
|
||||
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
56
e2e/dashboard.spec.ts
Normal file
56
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Dashboard", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("shows GearBox heading", async ({ page }) => {
|
||||
await expect(page.getByText("GearBox")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows collection card with item count of 6", async ({ page }) => {
|
||||
// The Collection card link contains "Items" label and value "6"
|
||||
const collectionCard = page
|
||||
.getByRole("link", { name: /collection/i })
|
||||
.first();
|
||||
await expect(collectionCard).toBeVisible();
|
||||
await expect(collectionCard.getByText("6")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows Collection, Planning, and Setups card headings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Collection" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Planning" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Collection card links to /collection", async ({ page }) => {
|
||||
const collectionLink = page
|
||||
.getByRole("link", { name: /collection/i })
|
||||
.first();
|
||||
await collectionLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page).toHaveURL(/\/collection/);
|
||||
});
|
||||
|
||||
test("shows active thread count on Planning card", async ({ page }) => {
|
||||
// The Planning card is a link containing "Active threads"
|
||||
const planningCard = page.getByRole("link", { name: /planning/i });
|
||||
await expect(planningCard.getByText("Active threads")).toBeVisible();
|
||||
// Seed has 1 active thread
|
||||
await expect(planningCard.getByText("1")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows setup count on Setups card", async ({ page }) => {
|
||||
// The Setups card has a heading "Setups"
|
||||
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
|
||||
// Seed has 1 setup
|
||||
const setupsCard = page.getByRole("link", { name: /setups/i }).last();
|
||||
await expect(setupsCard.getByText("1")).toBeVisible();
|
||||
});
|
||||
});
|
||||
59
e2e/error-handling.spec.ts
Normal file
59
e2e/error-handling.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Error handling — non-existent routes", () => {
|
||||
test("non-existent thread does not white-screen", async ({ page }) => {
|
||||
await page.goto("/threads/99999");
|
||||
|
||||
// React Query retries failed requests (default 3 times with backoff) before
|
||||
// setting isError=true. Wait for the page to leave loading state and show content.
|
||||
// The thread detail page renders "Thread not found" + "Back to planning" link on error.
|
||||
await expect(page.getByText("Back to planning")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText("Thread not found")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("non-existent setup does not white-screen", async ({ page }) => {
|
||||
await page.goto("/setups/99999");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Setup detail shows "Setup not found." when data is null (no retry — isLoading=false, data=undefined)
|
||||
// The setup query resolves with undefined rather than throwing for missing items.
|
||||
// Check that the page has content (not a blank screen).
|
||||
const body = page.locator("body");
|
||||
await expect(body).not.toBeEmpty();
|
||||
|
||||
// Navigation header should be visible (app did not crash)
|
||||
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Wait for setup data to load; it will show "Setup not found." when done
|
||||
await expect(page.getByText("Setup not found.")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
test("app recovers after bad route — dashboard loads fine", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to a non-existent thread first
|
||||
await page.goto("/threads/99999");
|
||||
|
||||
// The page should render without crashing — wait for the error state
|
||||
// (React Query retries before showing isError state, so use a long timeout)
|
||||
await expect(page.getByText("Back to planning")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Now navigate to the dashboard
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Dashboard should load normally
|
||||
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole("heading", { name: "Collection" })).toBeVisible(
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
10
e2e/global-setup.ts
Normal file
10
e2e/global-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { seedTestDatabase } from "./seed";
|
||||
|
||||
export default async function globalSetup() {
|
||||
await seedTestDatabase();
|
||||
}
|
||||
|
||||
// Allow direct invocation: bun run e2e/global-setup.ts
|
||||
if (import.meta.main) {
|
||||
await globalSetup();
|
||||
}
|
||||
220
e2e/seed.ts
Normal file
220
e2e/seed.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import * as schema from "../src/db/schema";
|
||||
|
||||
const DB_PATH = "./e2e/test.db";
|
||||
|
||||
export async function seedTestDatabase() {
|
||||
// Remove old test DB if it exists
|
||||
try {
|
||||
await unlink(DB_PATH);
|
||||
} catch {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
|
||||
const sqlite = new Database(DB_PATH);
|
||||
sqlite.run("PRAGMA journal_mode = WAL");
|
||||
sqlite.run("PRAGMA foreign_keys = ON");
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
|
||||
// ── Categories ──
|
||||
const [uncategorized] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Uncategorized", icon: "package" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
const [shelter] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Shelter", icon: "tent" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
const [sleep] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Sleep System", icon: "moon" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
const [cook] = db
|
||||
.insert(schema.categories)
|
||||
.values({ name: "Cook Kit", icon: "flame" })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
// ── Items ──
|
||||
const tent = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Zpacks Duplex",
|
||||
weightGrams: 539,
|
||||
priceCents: 67900,
|
||||
categoryId: shelter.id,
|
||||
notes: "DCF shelter, 2-person",
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.items)
|
||||
.values({
|
||||
name: "Borah Gear Tarp",
|
||||
weightGrams: 156,
|
||||
priceCents: 11000,
|
||||
categoryId: shelter.id,
|
||||
})
|
||||
.run();
|
||||
|
||||
const quilt = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Enlightened Equipment Enigma 20",
|
||||
weightGrams: 595,
|
||||
priceCents: 34000,
|
||||
categoryId: sleep.id,
|
||||
notes: "20F quilt",
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const pad = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "Therm-a-Rest NeoAir XLite",
|
||||
weightGrams: 354,
|
||||
priceCents: 20999,
|
||||
categoryId: sleep.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const stove = db
|
||||
.insert(schema.items)
|
||||
.values({
|
||||
name: "BRS-3000T Stove",
|
||||
weightGrams: 25,
|
||||
priceCents: 2000,
|
||||
categoryId: cook.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.items)
|
||||
.values({
|
||||
name: "Toaks 750ml Pot",
|
||||
weightGrams: 103,
|
||||
priceCents: 3000,
|
||||
categoryId: cook.id,
|
||||
})
|
||||
.run();
|
||||
|
||||
// ── Active Thread with 3 Candidates ──
|
||||
const activeThread = db
|
||||
.insert(schema.threads)
|
||||
.values({
|
||||
name: "New Backpack",
|
||||
status: "active",
|
||||
categoryId: uncategorized.id,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: activeThread.id,
|
||||
name: "ULA Circuit",
|
||||
weightGrams: 1077,
|
||||
priceCents: 27500,
|
||||
categoryId: uncategorized.id,
|
||||
pros: "Great hip belt\nLarge capacity",
|
||||
cons: "Heavier than competitors",
|
||||
sortOrder: 1000,
|
||||
status: "researching",
|
||||
})
|
||||
.run();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: activeThread.id,
|
||||
name: "Gossamer Gear Mariposa",
|
||||
weightGrams: 737,
|
||||
priceCents: 28500,
|
||||
categoryId: uncategorized.id,
|
||||
pros: "Very lightweight\nGood ventilation",
|
||||
cons: "Smaller hip belt pockets",
|
||||
sortOrder: 2000,
|
||||
status: "researching",
|
||||
})
|
||||
.run();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: activeThread.id,
|
||||
name: "Granite Gear Crown2 38",
|
||||
weightGrams: 850,
|
||||
priceCents: 18000,
|
||||
categoryId: uncategorized.id,
|
||||
sortOrder: 3000,
|
||||
status: "ordered",
|
||||
})
|
||||
.run();
|
||||
|
||||
// ── Resolved Thread ──
|
||||
const resolvedThread = db
|
||||
.insert(schema.threads)
|
||||
.values({
|
||||
name: "Camp Stove",
|
||||
status: "resolved",
|
||||
categoryId: cook.id,
|
||||
resolvedCandidateId: 1,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId: resolvedThread.id,
|
||||
name: "BRS-3000T",
|
||||
weightGrams: 25,
|
||||
priceCents: 2000,
|
||||
categoryId: cook.id,
|
||||
sortOrder: 1000,
|
||||
status: "arrived",
|
||||
})
|
||||
.run();
|
||||
|
||||
// ── Setup with Items ──
|
||||
const setup = db
|
||||
.insert(schema.setups)
|
||||
.values({ name: "Weekend Overnighter" })
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
db.insert(schema.setupItems)
|
||||
.values([
|
||||
{ setupId: setup.id, itemId: tent.id, classification: "base" },
|
||||
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
|
||||
{ setupId: setup.id, itemId: pad.id, classification: "base" },
|
||||
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
|
||||
])
|
||||
.run();
|
||||
|
||||
// ── User ──
|
||||
const passwordHash = await Bun.password.hash("password123");
|
||||
db.insert(schema.users).values({ username: "admin", passwordHash }).run();
|
||||
|
||||
// ── Settings ──
|
||||
db.insert(schema.settings)
|
||||
.values([
|
||||
{ key: "weightUnit", value: "g" },
|
||||
{ key: "currency", value: "USD" },
|
||||
{ key: "onboardingComplete", value: "true" },
|
||||
])
|
||||
.run();
|
||||
|
||||
sqlite.close();
|
||||
console.log("E2E test database seeded at", DB_PATH);
|
||||
}
|
||||
4
e2e/start-test-server.sh
Executable file
4
e2e/start-test-server.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Seed the test database, then start the production server
|
||||
bun run e2e/global-setup.ts
|
||||
NODE_ENV=production DATABASE_PATH=./e2e/test.db bun run src/server/index.ts
|
||||
113
e2e/threads.spec.ts
Normal file
113
e2e/threads.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Thread detail page", () => {
|
||||
test("loads with candidates visible", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click the "New Backpack" thread card
|
||||
await page.getByText("New Backpack").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Thread detail page should show the thread name
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "New Backpack" }),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Candidates should be visible
|
||||
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText("Gossamer Gear Mariposa")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(page.getByText("Granite Gear Crown2 38")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("rank badges are visible for top 3 candidates", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByText("New Backpack").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Rank badges are medal icons — the component renders LucideIcon with name="medal"
|
||||
// for ranks 1, 2, 3. We can verify via SVG elements or by checking the page has
|
||||
// the expected number of medal icons (one per top candidate).
|
||||
// The list view is default, which renders CandidateListItem with RankBadge.
|
||||
// With 3 candidates all in top 3, all 3 get medal icons.
|
||||
await expect(page.locator("text=ULA Circuit")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify candidates are rendered (rank badges are SVG medals, not text)
|
||||
// Check that at least the 3 candidates are present in the list
|
||||
const candidateRows = page.locator(".bg-white.rounded-xl.border");
|
||||
await expect(candidateRows).toHaveCount(3, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test("comparison view toggle works", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByText("New Backpack").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The compare button is a button with title="Compare view" (icon button with columns-3 icon)
|
||||
const compareButton = page.getByRole("button", { name: "Compare view" });
|
||||
await expect(compareButton).toBeVisible({ timeout: 5000 });
|
||||
await compareButton.click();
|
||||
|
||||
// After clicking, a table should appear with Weight and Price row labels
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 5000 });
|
||||
// The comparison table renders row labels in sticky <td> cells (exact match to avoid
|
||||
// matching candidate notes that contain the word "weight" or "price")
|
||||
await expect(
|
||||
page.getByRole("cell", { name: "Weight", exact: true }),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(
|
||||
page.getByRole("cell", { name: "Price", exact: true }),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// All 3 candidates should appear as table column headers (in <thead>)
|
||||
await expect(page.locator("thead").getByText("ULA Circuit")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(
|
||||
page.locator("thead").getByText("Gossamer Gear Mariposa"),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("resolved thread shows winner banner", async ({ page }) => {
|
||||
await page.goto("/collection?tab=planning");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click the "Resolved" tab pill button in the planning view
|
||||
await page.getByRole("button", { name: "Resolved" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Camp Stove resolved thread should appear
|
||||
await expect(page.getByText("Camp Stove")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the Camp Stove thread card and wait for URL to change to thread detail
|
||||
await page.getByText("Camp Stove").click();
|
||||
await page.waitForURL(/\/threads\/\d+/, { timeout: 5000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should show the resolved thread heading
|
||||
await expect(page.getByRole("heading", { name: "Camp Stove" })).toBeVisible(
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// The winner candidate (BRS-3000T) should be visible in the candidate list
|
||||
await expect(page.getByText("BRS-3000T")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
101
package.json
101
package.json
@@ -1,50 +1,55 @@
|
||||
{
|
||||
"name": "gearbox",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -c \"blue,green\" -n \"server,client\" \"bun run dev:server\" \"bun run dev:client\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "bun --hot src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
"db:push": "bunx drizzle-kit push",
|
||||
"test": "bun test",
|
||||
"lint": "bunx @biomejs/biome check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@tanstack/react-router-devtools": "^1.166.7",
|
||||
"@tanstack/router-plugin": "^1.166.9",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.12.8",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
}
|
||||
"name": "gearbox",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -c \"blue,green\" -n \"server,client\" \"bun run dev:server\" \"bun run dev:client\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "bun --hot src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
"db:push": "bunx drizzle-kit push",
|
||||
"test": "bun test tests/",
|
||||
"test:e2e": "bunx playwright test",
|
||||
"test:e2e:ui": "bunx playwright test --ui",
|
||||
"lint": "bunx @biomejs/biome check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@tanstack/react-router-devtools": "^1.166.7",
|
||||
"@tanstack/router-plugin": "^1.166.9",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hono": "^4.12.8",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
25
playwright.config.ts
Normal file
25
playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "sh e2e/start-test-server.sh",
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30000,
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { RankBadge } from "./CandidateListItem";
|
||||
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface CandidateCardProps {
|
||||
@@ -22,6 +22,7 @@ interface CandidateCardProps {
|
||||
pros?: string | null;
|
||||
cons?: string | null;
|
||||
rank?: number;
|
||||
delta?: CandidateDelta;
|
||||
}
|
||||
|
||||
export function CandidateCard({
|
||||
@@ -40,9 +41,9 @@ export function CandidateCard({
|
||||
pros,
|
||||
cons,
|
||||
rank,
|
||||
delta,
|
||||
}: CandidateCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||
const openConfirmDeleteCandidate = useUIStore(
|
||||
(s) => s.openConfirmDeleteCandidate,
|
||||
@@ -165,14 +166,16 @@ export function CandidateCard({
|
||||
{rank != null && <RankBadge rank={rank} />}
|
||||
{weightGrams != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||
{formatWeight(weightGrams, unit)}
|
||||
{weight(weightGrams)}
|
||||
</span>
|
||||
)}
|
||||
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||
{priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(priceCents, currency)}
|
||||
{price(priceCents)}
|
||||
</span>
|
||||
)}
|
||||
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
<LucideIcon
|
||||
name={categoryIcon}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { Reorder } from "framer-motion";
|
||||
import { useRef } from "react";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface CandidateWithCategory {
|
||||
@@ -30,6 +31,8 @@ interface CandidateListItemProps {
|
||||
rank: number;
|
||||
isActive: boolean;
|
||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||
delta?: CandidateDelta;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
||||
@@ -51,10 +54,11 @@ export function CandidateListItem({
|
||||
rank,
|
||||
isActive,
|
||||
onStatusChange,
|
||||
delta,
|
||||
onDragEnd,
|
||||
}: CandidateListItemProps) {
|
||||
const controls = useDragControls();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const isDragging = useRef(false);
|
||||
const { weight, price } = useFormatters();
|
||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||
const openConfirmDeleteCandidate = useUIStore(
|
||||
(s) => s.openConfirmDeleteCandidate,
|
||||
@@ -62,23 +66,16 @@ export function CandidateListItem({
|
||||
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={candidate}
|
||||
dragControls={controls}
|
||||
dragListener={false}
|
||||
className="flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
const sharedClassName =
|
||||
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm group cursor-default";
|
||||
|
||||
const innerContent = (
|
||||
<>
|
||||
{/* Drag handle indicator */}
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<span className="text-gray-300 shrink-0">
|
||||
<LucideIcon name="grip-vertical" size={16} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rank badge */}
|
||||
@@ -104,7 +101,10 @@ export function CandidateListItem({
|
||||
{/* Name + badges */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCandidateEditPanel(candidate.id)}
|
||||
onClick={() => {
|
||||
if (isDragging.current) return;
|
||||
openCandidateEditPanel(candidate.id);
|
||||
}}
|
||||
className="flex-1 min-w-0 text-left"
|
||||
>
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
@@ -113,14 +113,16 @@ export function CandidateListItem({
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{candidate.weightGrams != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||
{formatWeight(candidate.weightGrams, unit)}
|
||||
{weight(candidate.weightGrams)}
|
||||
</span>
|
||||
)}
|
||||
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||
{candidate.priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(candidate.priceCents, currency)}
|
||||
{price(candidate.priceCents)}
|
||||
</span>
|
||||
)}
|
||||
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
<LucideIcon
|
||||
name={candidate.categoryIcon}
|
||||
@@ -206,6 +208,31 @@ export function CandidateListItem({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
// Reorder.Item requires a Reorder.Group parent — only use it in active threads
|
||||
if (isActive) {
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={candidate}
|
||||
onDragStart={() => {
|
||||
isDragging.current = true;
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setTimeout(() => {
|
||||
isDragging.current = false;
|
||||
}, 0);
|
||||
onDragEnd?.();
|
||||
}}
|
||||
whileDrag={{ cursor: "grabbing" }}
|
||||
style={{ marginBottom: 8, cursor: "grab" }}
|
||||
className={sharedClassName}
|
||||
>
|
||||
{innerContent}
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={sharedClassName}>{innerContent}</div>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { IconPicker } from "./IconPicker";
|
||||
|
||||
@@ -23,8 +21,7 @@ export function CategoryHeader({
|
||||
totalCost,
|
||||
itemCount,
|
||||
}: CategoryHeaderProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(name);
|
||||
const [editIcon, setEditIcon] = useState(icon);
|
||||
@@ -88,8 +85,8 @@ export function CategoryHeader({
|
||||
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
|
||||
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
|
||||
{itemCount} {itemCount === 1 ? "item" : "items"} · {weight(totalWeight)}{" "}
|
||||
· {price(totalCost)}
|
||||
</span>
|
||||
{!isUncategorized && (
|
||||
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
276
src/client/components/CollectionView.tsx
Normal file
276
src/client/components/CollectionView.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCategories } from "../hooks/useCategories";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { useItems } from "../hooks/useItems";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||
import { CategoryHeader } from "./CategoryHeader";
|
||||
import { ItemCard } from "./ItemCard";
|
||||
|
||||
export function CollectionView() {
|
||||
const { data: items, isLoading: itemsLoading } = useItems();
|
||||
const { data: totals } = useTotals();
|
||||
const { data: categories } = useCategories();
|
||||
const { weight, price } = useFormatters();
|
||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!items) return [];
|
||||
return items.filter((item) => {
|
||||
const matchesSearch =
|
||||
searchText === "" ||
|
||||
item.name.toLowerCase().includes(searchText.toLowerCase());
|
||||
const matchesCategory =
|
||||
categoryFilter === null || item.categoryId === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [items, searchText, categoryFilter]);
|
||||
|
||||
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
|
||||
|
||||
if (itemsLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-gray-200 rounded w-48" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="mb-4">
|
||||
<LucideIcon
|
||||
name="backpack"
|
||||
size={48}
|
||||
className="text-gray-400 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Your collection is empty
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Start cataloging your gear by adding your first item. Track weight,
|
||||
price, and organize by category.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddPanel}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add your first item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build category totals lookup
|
||||
const categoryTotalsMap = new Map<
|
||||
number,
|
||||
{ totalWeight: number; totalCost: number; itemCount: number }
|
||||
>();
|
||||
if (totals?.categories) {
|
||||
for (const ct of totals.categories) {
|
||||
categoryTotalsMap.set(ct.categoryId, {
|
||||
totalWeight: ct.totalWeight,
|
||||
totalCost: ct.totalCost,
|
||||
itemCount: ct.itemCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Group filtered items by categoryId (used when no active filters)
|
||||
const groupedItems = new Map<
|
||||
number,
|
||||
{
|
||||
items: typeof filteredItems;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const item of filteredItems) {
|
||||
const group = groupedItems.get(item.categoryId);
|
||||
if (group) {
|
||||
group.items.push(item);
|
||||
} else {
|
||||
groupedItems.set(item.categoryId, {
|
||||
items: [item],
|
||||
categoryName: item.categoryName,
|
||||
categoryIcon: item.categoryIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Collection stats card */}
|
||||
{totals?.global && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500">Items</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{totals.global.itemCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500">Total Weight</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{weight(totals.global.totalWeight)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon
|
||||
name="credit-card"
|
||||
size={14}
|
||||
className="text-gray-400"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Total Spent</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{price(totals.global.totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search/filter toolbar */}
|
||||
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search items..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CategoryFilterDropdown
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
categories={categories ?? []}
|
||||
/>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Showing {filteredItems.length} of {items.length} items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtered results */}
|
||||
{hasActiveFilters ? (
|
||||
filteredItems.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-gray-500">No items match your search</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredItems.map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
quantity={item.quantity}
|
||||
categoryName={item.categoryName}
|
||||
categoryIcon={item.categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
productUrl={item.productUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
Array.from(groupedItems.entries()).map(
|
||||
([
|
||||
categoryId,
|
||||
{ items: categoryItems, categoryName, categoryIcon },
|
||||
]) => {
|
||||
const catTotals = categoryTotalsMap.get(categoryId);
|
||||
return (
|
||||
<div key={categoryId} className="mb-8">
|
||||
<CategoryHeader
|
||||
categoryId={categoryId}
|
||||
name={categoryName}
|
||||
icon={categoryIcon}
|
||||
totalWeight={catTotals?.totalWeight ?? 0}
|
||||
totalCost={catTotals?.totalCost ?? 0}
|
||||
itemCount={catTotals?.itemCount ?? categoryItems.length}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryItems.map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
quantity={item.quantity}
|
||||
categoryName={categoryName}
|
||||
categoryIcon={categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
productUrl={item.productUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { RankBadge } from "./CandidateListItem";
|
||||
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||
|
||||
interface CandidateWithCategory {
|
||||
id: number;
|
||||
@@ -28,6 +28,7 @@ interface CandidateWithCategory {
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
deltas?: Record<number, CandidateDelta>;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||
@@ -39,9 +40,9 @@ const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||
export function ComparisonTable({
|
||||
candidates,
|
||||
resolvedCandidateId,
|
||||
deltas,
|
||||
}: ComparisonTableProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
|
||||
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||
@@ -62,8 +63,7 @@ export function ComparisonTable({
|
||||
weightDeltas[c.id] = null;
|
||||
} else {
|
||||
const delta = c.weightGrams - minWeight;
|
||||
weightDeltas[c.id] =
|
||||
delta === 0 ? null : `+${formatWeight(delta, unit)}`;
|
||||
weightDeltas[c.id] = delta === 0 ? null : `+${weight(delta)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -88,8 +88,7 @@ export function ComparisonTable({
|
||||
priceDeltas[c.id] = null;
|
||||
} else {
|
||||
const delta = c.priceCents - minPrice;
|
||||
priceDeltas[c.id] =
|
||||
delta === 0 ? null : `+${formatPrice(delta, currency)}`;
|
||||
priceDeltas[c.id] = delta === 0 ? null : `+${price(delta)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -99,7 +98,7 @@ export function ComparisonTable({
|
||||
}
|
||||
|
||||
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||
}, [candidates, unit, currency]);
|
||||
}, [candidates, weight, price]);
|
||||
|
||||
const ATTRIBUTE_ROWS: Array<{
|
||||
key: string;
|
||||
@@ -155,7 +154,7 @@ export function ComparisonTable({
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatWeight(c.weightGrams, unit)}
|
||||
{weight(c.weightGrams)}
|
||||
</span>
|
||||
{!isBest && delta && (
|
||||
<div className="text-xs text-gray-400">{delta}</div>
|
||||
@@ -181,7 +180,7 @@ export function ComparisonTable({
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatPrice(c.priceCents, currency)}
|
||||
{price(c.priceCents)}
|
||||
</span>
|
||||
{!isBest && delta && (
|
||||
<div className="text-xs text-gray-400">{delta}</div>
|
||||
@@ -268,6 +267,10 @@ export function ComparisonTable({
|
||||
},
|
||||
];
|
||||
|
||||
// Determine if impact rows should be shown
|
||||
const firstDelta = deltas ? Object.values(deltas)[0] : undefined;
|
||||
const showImpact = !!deltas && !!firstDelta && firstDelta.mode !== "none";
|
||||
|
||||
const tableMinWidth = Math.max(400, candidates.length * 180);
|
||||
|
||||
return (
|
||||
@@ -329,6 +332,50 @@ export function ComparisonTable({
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{showImpact && (
|
||||
<>
|
||||
<tr className="border-b border-gray-50">
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
|
||||
Weight Impact
|
||||
</td>
|
||||
{candidates.map((candidate) => {
|
||||
const isWinner = candidate.id === resolvedCandidateId;
|
||||
return (
|
||||
<td
|
||||
key={candidate.id}
|
||||
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
|
||||
>
|
||||
<ImpactDeltaBadge
|
||||
delta={deltas?.[candidate.id]}
|
||||
type="weight"
|
||||
formatFn={weight}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
<tr className="border-b border-gray-50">
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
|
||||
Price Impact
|
||||
</td>
|
||||
{candidates.map((candidate) => {
|
||||
const isWinner = candidate.id === resolvedCandidateId;
|
||||
return (
|
||||
<td
|
||||
key={candidate.id}
|
||||
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
|
||||
>
|
||||
<ImpactDeltaBadge
|
||||
delta={deltas?.[candidate.id]}
|
||||
type="price"
|
||||
formatFn={price}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
39
src/client/components/ImpactDeltaBadge.tsx
Normal file
39
src/client/components/ImpactDeltaBadge.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||
|
||||
interface ImpactDeltaBadgeProps {
|
||||
delta: CandidateDelta | undefined;
|
||||
type: "weight" | "price";
|
||||
formatFn: (value: number) => string;
|
||||
}
|
||||
|
||||
export function ImpactDeltaBadge({
|
||||
delta,
|
||||
type,
|
||||
formatFn,
|
||||
}: ImpactDeltaBadgeProps) {
|
||||
if (!delta || delta.mode === "none") return null;
|
||||
|
||||
const value = type === "weight" ? delta.weightDelta : delta.priceDelta;
|
||||
|
||||
if (value === null) {
|
||||
return <span className="text-xs text-gray-400">—</span>;
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return <span className="text-xs text-gray-400">±0</span>;
|
||||
}
|
||||
|
||||
if (value > 0) {
|
||||
return (
|
||||
<span className="text-xs text-green-600">
|
||||
+{formatFn(value)}
|
||||
{delta.mode === "add" && (
|
||||
<span className="ml-0.5 text-green-500">(add)</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// value < 0
|
||||
return <span className="text-xs text-red-500">{formatFn(value)}</span>;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { useDuplicateItem } from "../hooks/useItems";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { ClassificationBadge } from "./ClassificationBadge";
|
||||
@@ -10,6 +9,7 @@ interface ItemCardProps {
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
quantity?: number;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
imageFilename: string | null;
|
||||
@@ -24,6 +24,7 @@ export function ItemCard({
|
||||
name,
|
||||
weightGrams,
|
||||
priceCents,
|
||||
quantity,
|
||||
categoryName,
|
||||
categoryIcon,
|
||||
imageFilename,
|
||||
@@ -32,10 +33,10 @@ export function ItemCard({
|
||||
classification,
|
||||
onClassificationCycle,
|
||||
}: ItemCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||
const duplicateItem = useDuplicateItem();
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -43,6 +44,46 @@ export function ItemCard({
|
||||
onClick={() => openEditPanel(id)}
|
||||
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
||||
>
|
||||
{!onRemove && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateItem.mutate(id, {
|
||||
onSuccess: (newItem) => {
|
||||
openEditPanel(newItem.id);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
duplicateItem.mutate(id, {
|
||||
onSuccess: (newItem) => {
|
||||
openEditPanel(newItem.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
||||
title="Duplicate item"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{productUrl && (
|
||||
<span
|
||||
role="button"
|
||||
@@ -125,18 +166,25 @@ export function ItemCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 truncate min-w-0">
|
||||
{name}
|
||||
</h3>
|
||||
{quantity != null && quantity > 1 && (
|
||||
<span className="shrink-0 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||
×{quantity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{weightGrams != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||
{formatWeight(weightGrams, unit)}
|
||||
{weight(weightGrams)}
|
||||
</span>
|
||||
)}
|
||||
{priceCents != null && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(priceCents, currency)}
|
||||
{price(priceCents)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||
|
||||
@@ -13,6 +13,7 @@ interface FormData {
|
||||
name: string;
|
||||
weightGrams: string;
|
||||
priceDollars: string;
|
||||
quantity: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
@@ -23,6 +24,7 @@ const INITIAL_FORM: FormData = {
|
||||
name: "",
|
||||
weightGrams: "",
|
||||
priceDollars: "",
|
||||
quantity: 1,
|
||||
categoryId: 1,
|
||||
notes: "",
|
||||
productUrl: "",
|
||||
@@ -49,6 +51,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
||||
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||
priceDollars:
|
||||
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
|
||||
quantity: item.quantity ?? 1,
|
||||
categoryId: item.categoryId,
|
||||
notes: item.notes ?? "",
|
||||
productUrl: item.productUrl ?? "",
|
||||
@@ -98,6 +101,7 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
||||
priceCents: form.priceDollars
|
||||
? Math.round(Number(form.priceDollars) * 100)
|
||||
: undefined,
|
||||
quantity: form.quantity,
|
||||
categoryId: form.categoryId,
|
||||
notes: form.notes.trim() || undefined,
|
||||
productUrl: form.productUrl.trim() || undefined,
|
||||
@@ -202,6 +206,30 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="item-quantity"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
id="item-quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={form.quantity}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
quantity: Math.max(1, Number(e.target.value) || 1),
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { useItems } from "../hooks/useItems";
|
||||
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { SlideOutPanel } from "./SlideOutPanel";
|
||||
|
||||
@@ -22,8 +20,7 @@ export function ItemPicker({
|
||||
}: ItemPickerProps) {
|
||||
const { data: items } = useItems();
|
||||
const syncItems = useSyncSetupItems(setupId);
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Reset selected IDs when panel opens
|
||||
@@ -117,13 +114,11 @@ export function ItemPicker({
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{item.weightGrams != null &&
|
||||
formatWeight(item.weightGrams, unit)}
|
||||
{item.weightGrams != null && weight(item.weightGrams)}
|
||||
{item.weightGrams != null &&
|
||||
item.priceCents != null &&
|
||||
" · "}
|
||||
{item.priceCents != null &&
|
||||
formatPrice(item.priceCents, currency)}
|
||||
{item.priceCents != null && price(item.priceCents)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
196
src/client/components/PlanningView.tsx
Normal file
196
src/client/components/PlanningView.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from "react";
|
||||
import { useCategories } from "../hooks/useCategories";
|
||||
import { useThreads } from "../hooks/useThreads";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||
import { CreateThreadModal } from "./CreateThreadModal";
|
||||
import { ThreadCard } from "./ThreadCard";
|
||||
|
||||
export function PlanningView() {
|
||||
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||
|
||||
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
|
||||
const { data: categories } = useCategories();
|
||||
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter threads by active tab and category
|
||||
const filteredThreads = (threads ?? [])
|
||||
.filter((t) => t.status === activeTab)
|
||||
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
|
||||
|
||||
// Determine if we should show the educational empty state
|
||||
const isEmptyNoFilters =
|
||||
filteredThreads.length === 0 &&
|
||||
activeTab === "active" &&
|
||||
categoryFilter === null &&
|
||||
(!threads || threads.length === 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Planning Threads
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateThreadModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Thread
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Pill tabs */}
|
||||
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("active")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||
activeTab === "active"
|
||||
? "bg-gray-700 text-white"
|
||||
: "text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("resolved")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||
activeTab === "resolved"
|
||||
? "bg-gray-700 text-white"
|
||||
: "text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
Resolved
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<CategoryFilterDropdown
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
categories={categories ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content: empty state or thread grid */}
|
||||
{isEmptyNoFilters ? (
|
||||
<div className="py-16">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||
Plan your next purchase
|
||||
</h2>
|
||||
<div className="space-y-6 text-left mb-10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Create a thread</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Start a research thread for gear you're considering
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add candidates</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Add products you're comparing with prices and weights
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Pick a winner</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Resolve the thread and the winner joins your collection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateThreadModal}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Create your first thread
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredThreads.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-gray-500">No threads found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredThreads.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
id={thread.id}
|
||||
name={thread.name}
|
||||
candidateCount={thread.candidateCount}
|
||||
minPriceCents={thread.minPriceCents}
|
||||
maxPriceCents={thread.maxPriceCents}
|
||||
createdAt={thread.createdAt}
|
||||
status={thread.status}
|
||||
categoryName={thread.categoryName}
|
||||
categoryIcon={thread.categoryIcon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateThreadModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
|
||||
interface SetupCardProps {
|
||||
id: number;
|
||||
@@ -18,8 +16,7 @@ export function SetupCard({
|
||||
totalWeight,
|
||||
totalCost,
|
||||
}: SetupCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
return (
|
||||
<Link
|
||||
to="/setups/$setupId"
|
||||
@@ -34,10 +31,10 @@ export function SetupCard({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||
{formatWeight(totalWeight, unit)}
|
||||
{weight(totalWeight)}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||
{formatPrice(totalCost, currency)}
|
||||
{price(totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
34
src/client/components/SetupImpactSelector.tsx
Normal file
34
src/client/components/SetupImpactSelector.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useSetups } from "../hooks/useSetups";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
interface SetupImpactSelectorProps {
|
||||
threadStatus: "active" | "resolved";
|
||||
}
|
||||
|
||||
export function SetupImpactSelector({
|
||||
threadStatus,
|
||||
}: SetupImpactSelectorProps) {
|
||||
const { data: setups } = useSetups();
|
||||
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
|
||||
|
||||
if (threadStatus !== "active") return null;
|
||||
if (!setups || setups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<select
|
||||
value={selectedSetupId ?? ""}
|
||||
onChange={(e) =>
|
||||
setSelectedSetupId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
className="border border-gray-200 rounded-lg text-sm px-3 py-1.5 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-300"
|
||||
>
|
||||
<option value="">Compare with setup...</option>
|
||||
{setups.map((setup) => (
|
||||
<option key={setup.id} value={setup.id}>
|
||||
{setup.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
112
src/client/components/SetupsView.tsx
Normal file
112
src/client/components/SetupsView.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import { useCreateSetup, useSetups } from "../hooks/useSetups";
|
||||
import { SetupCard } from "./SetupCard";
|
||||
|
||||
export function SetupsView() {
|
||||
const [newSetupName, setNewSetupName] = useState("");
|
||||
const { data: setups, isLoading } = useSetups();
|
||||
const createSetup = useCreateSetup();
|
||||
|
||||
function handleCreateSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newSetupName.trim();
|
||||
if (!name) return;
|
||||
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Create setup form */}
|
||||
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={newSetupName}
|
||||
onChange={(e) => setNewSetupName(e.target.value)}
|
||||
placeholder="New setup name..."
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newSetupName.trim() || createSetup.isPending}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{createSetup.isPending ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-24 bg-gray-200 rounded-xl animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && (!setups || setups.length === 0) && (
|
||||
<div className="py-16">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||
Build your perfect loadout
|
||||
</h2>
|
||||
<div className="space-y-6 text-left mb-10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Create a setup</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Name your loadout for a specific trip or activity
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add items</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Pick gear from your collection to include in the setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Track weight</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
See weight breakdown and optimize your pack
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup grid */}
|
||||
{!isLoading && setups && setups.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{setups.map((setup) => (
|
||||
<SetupCard
|
||||
key={setup.id}
|
||||
id={setup.id}
|
||||
name={setup.name}
|
||||
itemCount={setup.itemCount}
|
||||
totalWeight={setup.totalWeight}
|
||||
totalCost={setup.totalCost}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { formatPrice } from "../lib/formatters";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
interface ThreadCardProps {
|
||||
@@ -20,16 +19,6 @@ function formatDate(iso: string): string {
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatPriceRange(
|
||||
min: number | null,
|
||||
max: number | null,
|
||||
currency: Parameters<typeof formatPrice>[1],
|
||||
): string | null {
|
||||
if (min == null && max == null) return null;
|
||||
if (min === max) return formatPrice(min, currency);
|
||||
return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
|
||||
}
|
||||
|
||||
export function ThreadCard({
|
||||
id,
|
||||
name,
|
||||
@@ -42,10 +31,19 @@ export function ThreadCard({
|
||||
categoryIcon,
|
||||
}: ThreadCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const currency = useCurrency();
|
||||
const { price } = useFormatters();
|
||||
|
||||
function formatPriceRange(
|
||||
min: number | null,
|
||||
max: number | null,
|
||||
): string | null {
|
||||
if (min == null && max == null) return null;
|
||||
if (min === max) return price(min);
|
||||
return `${price(min)} - ${price(max)}`;
|
||||
}
|
||||
|
||||
const isResolved = status === "resolved";
|
||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
|
||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useAuth, useLogout } from "../hooks/useAuth";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight, type WeightUnit } from "../lib/formatters";
|
||||
import type { WeightUnit } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
||||
@@ -19,7 +20,10 @@ export function TotalsBar({
|
||||
linkTo,
|
||||
}: TotalsBarProps) {
|
||||
const { data } = useTotals();
|
||||
const unit = useWeightUnit();
|
||||
const { data: auth } = useAuth();
|
||||
const logout = useLogout();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
const { weight, price, unit } = useFormatters();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
// When no stats provided, use global totals (backward compatible)
|
||||
@@ -30,14 +34,14 @@ export function TotalsBar({
|
||||
{ label: "items", value: String(data.global.itemCount) },
|
||||
{
|
||||
label: "total",
|
||||
value: formatWeight(data.global.totalWeight, unit),
|
||||
value: weight(data.global.totalWeight),
|
||||
},
|
||||
{ label: "spent", value: formatPrice(data.global.totalCost) },
|
||||
{ label: "spent", value: price(data.global.totalCost) },
|
||||
]
|
||||
: [
|
||||
{ label: "items", value: "0" },
|
||||
{ label: "total", value: formatWeight(null, unit) },
|
||||
{ label: "spent", value: formatPrice(null) },
|
||||
{ label: "total", value: weight(null) },
|
||||
{ label: "spent", value: price(null) },
|
||||
]);
|
||||
|
||||
const titleContent = (
|
||||
@@ -100,6 +104,24 @@ export function TotalsBar({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout.mutate()}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
|
||||
@@ -55,9 +55,15 @@ function buildCategoryChartData(items: SetupItemWithCategory[]): ChartDatum[] {
|
||||
const groups = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
const current = groups.get(item.categoryName) ?? 0;
|
||||
groups.set(item.categoryName, current + (item.weightGrams ?? 0));
|
||||
groups.set(
|
||||
item.categoryName,
|
||||
current + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||
);
|
||||
}
|
||||
const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
||||
const total = items.reduce(
|
||||
(sum, i) => sum + (i.weightGrams ?? 0) * (i.quantity ?? 1),
|
||||
0,
|
||||
);
|
||||
return Array.from(groups.entries())
|
||||
.filter(([, weight]) => weight > 0)
|
||||
.map(([name, weight]) => ({
|
||||
@@ -76,7 +82,8 @@ function buildClassificationChartData(
|
||||
consumable: 0,
|
||||
};
|
||||
for (const item of items) {
|
||||
groups[item.classification] += item.weightGrams ?? 0;
|
||||
groups[item.classification] +=
|
||||
(item.weightGrams ?? 0) * (item.quantity ?? 1);
|
||||
}
|
||||
const total = Object.values(groups).reduce((a, b) => a + b, 0);
|
||||
return Object.entries(groups)
|
||||
@@ -143,22 +150,28 @@ function LegendRow({
|
||||
}
|
||||
|
||||
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||
const unit = useWeightUnit();
|
||||
const { unit } = useFormatters();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
||||
|
||||
const baseWeight = items.reduce(
|
||||
(sum, i) =>
|
||||
i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum,
|
||||
i.classification === "base"
|
||||
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||
: sum,
|
||||
0,
|
||||
);
|
||||
const wornWeight = items.reduce(
|
||||
(sum, i) =>
|
||||
i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum,
|
||||
i.classification === "worn"
|
||||
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||
: sum,
|
||||
0,
|
||||
);
|
||||
const consumableWeight = items.reduce(
|
||||
(sum, i) =>
|
||||
i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum,
|
||||
i.classification === "consumable"
|
||||
? sum + (i.weightGrams ?? 0) * (i.quantity ?? 1)
|
||||
: sum,
|
||||
0,
|
||||
);
|
||||
const totalWeight = baseWeight + wornWeight + consumableWeight;
|
||||
@@ -264,12 +277,20 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||
label="Consumable"
|
||||
weight={consumableWeight}
|
||||
unit={unit}
|
||||
percent={totalWeight > 0 ? consumableWeight / totalWeight : undefined}
|
||||
percent={
|
||||
totalWeight > 0 ? consumableWeight / totalWeight : undefined
|
||||
}
|
||||
/>
|
||||
<div className="border-t border-gray-200 mt-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<LucideIcon name="sigma" size={10} className="text-gray-400 shrink-0 ml-0.5" />
|
||||
<span className="text-sm font-medium text-gray-700 flex-1">Total</span>
|
||||
<LucideIcon
|
||||
name="sigma"
|
||||
size={10}
|
||||
className="text-gray-400 shrink-0 ml-0.5"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 flex-1">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
||||
{formatWeight(totalWeight, unit)}
|
||||
</span>
|
||||
|
||||
97
src/client/hooks/useAuth.ts
Normal file
97
src/client/hooks/useAuth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||
|
||||
interface AuthState {
|
||||
user: { id: number } | null;
|
||||
setupRequired: boolean;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useQuery({
|
||||
queryKey: ["auth"],
|
||||
queryFn: () => apiGet<AuthState>("/api/auth/me"),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) =>
|
||||
apiPost<{ username: string }>("/api/auth/login", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiPost<{ success: boolean }>("/api/auth/logout", {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) =>
|
||||
apiPost<{ username: string }>("/api/auth/setup", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangePassword() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||
apiPut<{ success: boolean }>("/api/auth/password", data),
|
||||
});
|
||||
}
|
||||
|
||||
interface ApiKeyListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ApiKeyResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export function useApiKeys() {
|
||||
return useQuery({
|
||||
queryKey: ["apiKeys"],
|
||||
queryFn: () => apiGet<ApiKeyListItem[]>("/api/auth/keys"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string }) =>
|
||||
apiPost<ApiKeyResponse>("/api/auth/keys", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiDelete<{ success: boolean }>(`/api/auth/keys/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
14
src/client/hooks/useFormatters.ts
Normal file
14
src/client/hooks/useFormatters.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { useCurrency } from "./useCurrency";
|
||||
import { useWeightUnit } from "./useWeightUnit";
|
||||
|
||||
export function useFormatters() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
return {
|
||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||
price: (cents: number | null) => formatPrice(cents, currency),
|
||||
unit,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
22
src/client/hooks/useImpactDeltas.ts
Normal file
22
src/client/hooks/useImpactDeltas.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
type CandidateDelta,
|
||||
type CandidateInput,
|
||||
computeImpactDeltas,
|
||||
type DeltaMode,
|
||||
type ImpactDeltas,
|
||||
type SetupItemInput,
|
||||
} from "../lib/impactDeltas";
|
||||
|
||||
export type { CandidateDelta, DeltaMode, ImpactDeltas };
|
||||
|
||||
export function useImpactDeltas(
|
||||
candidates: CandidateInput[],
|
||||
setupItems: SetupItemInput[] | undefined,
|
||||
threadCategoryId: number,
|
||||
): ImpactDeltas {
|
||||
return useMemo(
|
||||
() => computeImpactDeltas(candidates, setupItems, threadCategoryId),
|
||||
[candidates, setupItems, threadCategoryId],
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateItem } from "../../shared/types";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPut,
|
||||
apiUpload,
|
||||
} from "../lib/api";
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
quantity: number;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
imageFilename: string | null;
|
||||
imageSourceUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ItemWithCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
quantity: number;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
@@ -29,6 +52,8 @@ export function useItem(id: number | null) {
|
||||
queryKey: ["items", id],
|
||||
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
|
||||
enabled: id != null,
|
||||
retry: (count, error) =>
|
||||
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,3 +94,38 @@ export function useDeleteItem() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateItem() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiPost<Item>(`/api/items/${id}/duplicate`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["totals"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportItems() {
|
||||
return function exportItems() {
|
||||
window.location.href = "/api/items/export";
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
imported: number;
|
||||
createdCategories: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function useImportItems() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (file: File) =>
|
||||
apiUpload<ImportResult>("/api/items/import", file),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["totals"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "../lib/api";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from "../lib/api";
|
||||
|
||||
interface SetupListItem {
|
||||
id: number;
|
||||
@@ -16,6 +23,7 @@ interface SetupItemWithCategory {
|
||||
name: string;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
quantity: number;
|
||||
categoryId: number;
|
||||
notes: string | null;
|
||||
productUrl: string | null;
|
||||
@@ -49,6 +57,8 @@ export function useSetup(setupId: number | null) {
|
||||
queryKey: ["setups", setupId],
|
||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||
enabled: setupId != null,
|
||||
retry: (count, error) =>
|
||||
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +123,10 @@ export function useUpdateItemClassification(setupId: number) {
|
||||
mutationFn: ({
|
||||
itemId,
|
||||
classification,
|
||||
}: { itemId: number; classification: string }) =>
|
||||
}: {
|
||||
itemId: number;
|
||||
classification: string;
|
||||
}) =>
|
||||
apiPatch<{ success: boolean }>(
|
||||
`/api/setups/${setupId}/items/${itemId}/classification`,
|
||||
{ classification },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||
|
||||
interface ThreadListItem {
|
||||
id: number;
|
||||
@@ -40,6 +40,7 @@ interface ThreadWithCandidates {
|
||||
name: string;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
categoryId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
candidates: CandidateWithCategory[];
|
||||
@@ -60,6 +61,8 @@ export function useThread(threadId: number | null) {
|
||||
queryKey: ["threads", threadId],
|
||||
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
|
||||
enabled: threadId != null,
|
||||
retry: (count, error) =>
|
||||
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApiError extends Error {
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
|
||||
69
src/client/lib/impactDeltas.ts
Normal file
69
src/client/lib/impactDeltas.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export interface CandidateInput {
|
||||
id: number;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
}
|
||||
|
||||
export interface SetupItemInput {
|
||||
categoryId: number;
|
||||
weightGrams: number | null;
|
||||
priceCents: number | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type DeltaMode = "replace" | "add" | "none";
|
||||
|
||||
export interface CandidateDelta {
|
||||
candidateId: number;
|
||||
mode: DeltaMode;
|
||||
weightDelta: number | null;
|
||||
priceDelta: number | null;
|
||||
replacedItemName: string | null;
|
||||
}
|
||||
|
||||
export interface ImpactDeltas {
|
||||
mode: DeltaMode;
|
||||
deltas: Record<number, CandidateDelta>;
|
||||
}
|
||||
|
||||
export function computeImpactDeltas(
|
||||
candidates: CandidateInput[],
|
||||
setupItems: SetupItemInput[] | undefined,
|
||||
threadCategoryId: number,
|
||||
): ImpactDeltas {
|
||||
if (!setupItems) return { mode: "none", deltas: {} };
|
||||
|
||||
const replacedItem =
|
||||
setupItems.find((item) => item.categoryId === threadCategoryId) ?? null;
|
||||
const mode: DeltaMode = replacedItem ? "replace" : "add";
|
||||
const deltas: Record<number, CandidateDelta> = {};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
let weightDelta: number | null = null;
|
||||
let priceDelta: number | null = null;
|
||||
|
||||
if (candidate.weightGrams != null) {
|
||||
weightDelta =
|
||||
replacedItem?.weightGrams != null
|
||||
? candidate.weightGrams - replacedItem.weightGrams
|
||||
: candidate.weightGrams;
|
||||
}
|
||||
|
||||
if (candidate.priceCents != null) {
|
||||
priceDelta =
|
||||
replacedItem?.priceCents != null
|
||||
? candidate.priceCents - replacedItem.priceCents
|
||||
: candidate.priceCents;
|
||||
}
|
||||
|
||||
deltas[candidate.id] = {
|
||||
candidateId: candidate.id,
|
||||
mode,
|
||||
weightDelta,
|
||||
priceDelta,
|
||||
replacedItemName: replacedItem?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return { mode, deltas };
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
||||
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
||||
@@ -20,6 +21,11 @@ const SettingsRoute = SettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -43,6 +49,7 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
@@ -50,6 +57,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
@@ -58,6 +66,7 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
@@ -67,6 +76,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
@@ -74,6 +84,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
@@ -81,6 +92,7 @@ export interface FileRouteTypes {
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
@@ -89,6 +101,7 @@ export interface FileRouteTypes {
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||
@@ -104,6 +117,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -137,6 +157,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
createRootRoute,
|
||||
type ErrorComponentProps,
|
||||
Outlet,
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import "../app.css";
|
||||
@@ -13,6 +15,7 @@ import { ItemForm } from "../components/ItemForm";
|
||||
import { OnboardingWizard } from "../components/OnboardingWizard";
|
||||
import { SlideOutPanel } from "../components/SlideOutPanel";
|
||||
import { TotalsBar } from "../components/TotalsBar";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useDeleteCandidate } from "../hooks/useCandidates";
|
||||
import { useOnboardingComplete } from "../hooks/useSettings";
|
||||
import { useResolveThread, useThread } from "../hooks/useThreads";
|
||||
@@ -20,10 +23,57 @@ import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
errorComponent: RootErrorBoundary,
|
||||
});
|
||||
|
||||
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto text-center px-4">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-6 h-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset();
|
||||
router.invalidate();
|
||||
}}
|
||||
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { data: auth } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
|
||||
// Item panel state
|
||||
const panelMode = useUIStore((s) => s.panelMode);
|
||||
@@ -54,8 +104,12 @@ function RootLayout() {
|
||||
useOnboardingComplete();
|
||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||
|
||||
// Don't show onboarding wizard until user has created an account
|
||||
const showWizard =
|
||||
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
|
||||
!onboardingLoading &&
|
||||
onboardingComplete !== "true" &&
|
||||
!wizardDismissed &&
|
||||
isAuthenticated;
|
||||
|
||||
const isItemPanelOpen = panelMode !== "closed";
|
||||
const isCandidatePanelOpen = candidatePanelMode !== "closed";
|
||||
@@ -175,7 +229,7 @@ function RootLayout() {
|
||||
)}
|
||||
|
||||
{/* Floating Add Button - only on collection gear tab */}
|
||||
{showFab && (
|
||||
{showFab && isAuthenticated && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddPanel}
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { z } from "zod";
|
||||
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
|
||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||
import { CreateThreadModal } from "../../components/CreateThreadModal";
|
||||
import { ItemCard } from "../../components/ItemCard";
|
||||
import { SetupCard } from "../../components/SetupCard";
|
||||
import { ThreadCard } from "../../components/ThreadCard";
|
||||
import { useCategories } from "../../hooks/useCategories";
|
||||
import { useItems } from "../../hooks/useItems";
|
||||
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
|
||||
import { useThreads } from "../../hooks/useThreads";
|
||||
import { useTotals } from "../../hooks/useTotals";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
import { useUIStore } from "../../stores/uiStore";
|
||||
import { CollectionView } from "../../components/CollectionView";
|
||||
import { PlanningView } from "../../components/PlanningView";
|
||||
import { SetupsView } from "../../components/SetupsView";
|
||||
|
||||
const searchSchema = z.object({
|
||||
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||
@@ -29,6 +16,11 @@ export const Route = createFileRoute("/collection/")({
|
||||
});
|
||||
|
||||
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
||||
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
|
||||
gear: "Gear",
|
||||
planning: "Planning",
|
||||
setups: "Setups",
|
||||
};
|
||||
|
||||
const slideVariants = {
|
||||
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||
@@ -46,6 +38,26 @@ function CollectionPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
||||
{/* Tab navigation */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
||||
{TAB_ORDER.map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
to="/collection"
|
||||
search={{ tab: t }}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||
tab === t
|
||||
? "bg-gray-700 text-white"
|
||||
: "text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{TAB_LABELS[t]}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||
<motion.div
|
||||
key={tab}
|
||||
@@ -68,566 +80,3 @@ function CollectionPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionView() {
|
||||
const { data: items, isLoading: itemsLoading } = useItems();
|
||||
const { data: totals } = useTotals();
|
||||
const { data: categories } = useCategories();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!items) return [];
|
||||
return items.filter((item) => {
|
||||
const matchesSearch =
|
||||
searchText === "" ||
|
||||
item.name.toLowerCase().includes(searchText.toLowerCase());
|
||||
const matchesCategory =
|
||||
categoryFilter === null || item.categoryId === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [items, searchText, categoryFilter]);
|
||||
|
||||
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
|
||||
|
||||
if (itemsLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-gray-200 rounded w-48" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="mb-4">
|
||||
<LucideIcon
|
||||
name="backpack"
|
||||
size={48}
|
||||
className="text-gray-400 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Your collection is empty
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Start cataloging your gear by adding your first item. Track weight,
|
||||
price, and organize by category.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddPanel}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add your first item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build category totals lookup
|
||||
const categoryTotalsMap = new Map<
|
||||
number,
|
||||
{ totalWeight: number; totalCost: number; itemCount: number }
|
||||
>();
|
||||
if (totals?.categories) {
|
||||
for (const ct of totals.categories) {
|
||||
categoryTotalsMap.set(ct.categoryId, {
|
||||
totalWeight: ct.totalWeight,
|
||||
totalCost: ct.totalCost,
|
||||
itemCount: ct.itemCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Group filtered items by categoryId (used when no active filters)
|
||||
const groupedItems = new Map<
|
||||
number,
|
||||
{
|
||||
items: typeof filteredItems;
|
||||
categoryName: string;
|
||||
categoryIcon: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const item of filteredItems) {
|
||||
const group = groupedItems.get(item.categoryId);
|
||||
if (group) {
|
||||
group.items.push(item);
|
||||
} else {
|
||||
groupedItems.set(item.categoryId, {
|
||||
items: [item],
|
||||
categoryName: item.categoryName,
|
||||
categoryIcon: item.categoryIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Collection stats card */}
|
||||
{totals?.global && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500">Items</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{totals.global.itemCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500">Total Weight</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatWeight(totals.global.totalWeight, unit)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<LucideIcon
|
||||
name="credit-card"
|
||||
size={14}
|
||||
className="text-gray-400"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Total Spent</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatPrice(totals.global.totalCost, currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search/filter toolbar */}
|
||||
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search items..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CategoryFilterDropdown
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
categories={categories ?? []}
|
||||
/>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Showing {filteredItems.length} of {items.length} items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtered results */}
|
||||
{hasActiveFilters ? (
|
||||
filteredItems.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-gray-500">No items match your search</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredItems.map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
categoryName={item.categoryName}
|
||||
categoryIcon={item.categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
productUrl={item.productUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
Array.from(groupedItems.entries()).map(
|
||||
([
|
||||
categoryId,
|
||||
{ items: categoryItems, categoryName, categoryIcon },
|
||||
]) => {
|
||||
const catTotals = categoryTotalsMap.get(categoryId);
|
||||
return (
|
||||
<div key={categoryId} className="mb-8">
|
||||
<CategoryHeader
|
||||
categoryId={categoryId}
|
||||
name={categoryName}
|
||||
icon={categoryIcon}
|
||||
totalWeight={catTotals?.totalWeight ?? 0}
|
||||
totalCost={catTotals?.totalCost ?? 0}
|
||||
itemCount={catTotals?.itemCount ?? categoryItems.length}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryItems.map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
categoryName={categoryName}
|
||||
categoryIcon={categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
productUrl={item.productUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanningView() {
|
||||
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||
|
||||
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
|
||||
const { data: categories } = useCategories();
|
||||
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter threads by active tab and category
|
||||
const filteredThreads = (threads ?? [])
|
||||
.filter((t) => t.status === activeTab)
|
||||
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
|
||||
|
||||
// Determine if we should show the educational empty state
|
||||
const isEmptyNoFilters =
|
||||
filteredThreads.length === 0 &&
|
||||
activeTab === "active" &&
|
||||
categoryFilter === null &&
|
||||
(!threads || threads.length === 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Planning Threads
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateThreadModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Thread
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Pill tabs */}
|
||||
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("active")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||
activeTab === "active"
|
||||
? "bg-gray-700 text-white"
|
||||
: "text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("resolved")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||
activeTab === "resolved"
|
||||
? "bg-gray-700 text-white"
|
||||
: "text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
Resolved
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<CategoryFilterDropdown
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
categories={categories ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content: empty state or thread grid */}
|
||||
{isEmptyNoFilters ? (
|
||||
<div className="py-16">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||
Plan your next purchase
|
||||
</h2>
|
||||
<div className="space-y-6 text-left mb-10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Create a thread</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Start a research thread for gear you're considering
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add candidates</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Add products you're comparing with prices and weights
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Pick a winner</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Resolve the thread and the winner joins your collection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateThreadModal}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Create your first thread
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredThreads.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-gray-500">No threads found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredThreads.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
id={thread.id}
|
||||
name={thread.name}
|
||||
candidateCount={thread.candidateCount}
|
||||
minPriceCents={thread.minPriceCents}
|
||||
maxPriceCents={thread.maxPriceCents}
|
||||
createdAt={thread.createdAt}
|
||||
status={thread.status}
|
||||
categoryName={thread.categoryName}
|
||||
categoryIcon={thread.categoryIcon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateThreadModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupsView() {
|
||||
const [newSetupName, setNewSetupName] = useState("");
|
||||
const { data: setups, isLoading } = useSetups();
|
||||
const createSetup = useCreateSetup();
|
||||
|
||||
function handleCreateSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newSetupName.trim();
|
||||
if (!name) return;
|
||||
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Create setup form */}
|
||||
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={newSetupName}
|
||||
onChange={(e) => setNewSetupName(e.target.value)}
|
||||
placeholder="New setup name..."
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newSetupName.trim() || createSetup.isPending}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{createSetup.isPending ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-24 bg-gray-200 rounded-xl animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && (!setups || setups.length === 0) && (
|
||||
<div className="py-16">
|
||||
<div className="max-w-lg mx-auto text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||
Build your perfect loadout
|
||||
</h2>
|
||||
<div className="space-y-6 text-left mb-10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Create a setup</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Name your loadout for a specific trip or activity
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add items</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Pick gear from your collection to include in the setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Track weight</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
See weight breakdown and optimize your pack
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup grid */}
|
||||
{!isLoading && setups && setups.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{setups.map((setup) => (
|
||||
<SetupCard
|
||||
key={setup.id}
|
||||
id={setup.id}
|
||||
name={setup.name}
|
||||
itemCount={setup.itemCount}
|
||||
totalWeight={setup.totalWeight}
|
||||
totalCost={setup.totalCost}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DashboardCard } from "../components/DashboardCard";
|
||||
import { useFormatters } from "../hooks/useFormatters";
|
||||
import { useSetups } from "../hooks/useSetups";
|
||||
import { useThreads } from "../hooks/useThreads";
|
||||
import { useTotals } from "../hooks/useTotals";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: DashboardPage,
|
||||
@@ -15,8 +13,7 @@ function DashboardPage() {
|
||||
const { data: totals } = useTotals();
|
||||
const { data: threads } = useThreads(false);
|
||||
const { data: setups } = useSetups();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
|
||||
const global = totals?.global;
|
||||
const activeThreadCount = threads?.length ?? 0;
|
||||
@@ -33,9 +30,12 @@ function DashboardPage() {
|
||||
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
||||
{
|
||||
label: "Weight",
|
||||
value: formatWeight(global?.totalWeight ?? null, unit),
|
||||
value: weight(global?.totalWeight ?? null),
|
||||
},
|
||||
{
|
||||
label: "Cost",
|
||||
value: price(global?.totalCost ?? null),
|
||||
},
|
||||
{ label: "Cost", value: formatPrice(global?.totalCost ?? null, currency) },
|
||||
]}
|
||||
emptyText="Get started"
|
||||
/>
|
||||
|
||||
104
src/client/routes/login.tsx
Normal file
104
src/client/routes/login.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useAuth, useLogin, useSetup } from "../hooks/useAuth";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: LoginPage,
|
||||
});
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: auth } = useAuth();
|
||||
const login = useLogin();
|
||||
const setup = useSetup();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isSetup = auth?.setupRequired ?? false;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
try {
|
||||
if (isSetup) {
|
||||
await setup.mutateAsync({ username, password });
|
||||
} else {
|
||||
await login.mutateAsync({ username, password });
|
||||
}
|
||||
navigate({ to: "/" });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = login.isPending || setup.isPending;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
|
||||
{isSetup ? "Create Account" : "Sign In"}
|
||||
</h1>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-xl border border-gray-100 p-6 space-y-4"
|
||||
>
|
||||
{isSetup && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Create your admin account to manage your gear collection.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={isSetup ? 6 : undefined}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{isPending ? "..." : isSetup ? "Create Account" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
useApiKeys,
|
||||
useAuth,
|
||||
useChangePassword,
|
||||
useCreateApiKey,
|
||||
useDeleteApiKey,
|
||||
} from "../hooks/useAuth";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useExportItems, useImportItems } from "../hooks/useItems";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import type { Currency, WeightUnit } from "../lib/formatters";
|
||||
@@ -18,10 +27,246 @@ export const Route = createFileRoute("/settings")({
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
function ChangePasswordSection() {
|
||||
const changePassword = useChangePassword();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await changePassword.mutateAsync({ currentPassword, newPassword });
|
||||
setMessage({ type: "success", text: "Password changed" });
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Change Password</h3>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
{message && (
|
||||
<p
|
||||
className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changePassword.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{changePassword.isPending ? "..." : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeySection() {
|
||||
const { data: keys } = useApiKeys();
|
||||
const createKey = useCreateApiKey();
|
||||
const deleteKey = useDeleteApiKey();
|
||||
const [name, setName] = useState("");
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const result = await createKey.mutateAsync({ name });
|
||||
setNewKey(result.key);
|
||||
setName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">API Keys</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
API keys allow programmatic access to GearBox (e.g., from Claude Desktop
|
||||
or scripts).
|
||||
</p>
|
||||
|
||||
{newKey && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-xs font-medium text-amber-800 mb-1">
|
||||
Copy this key now — it won't be shown again:
|
||||
</p>
|
||||
<code className="text-xs text-amber-900 break-all select-all">
|
||||
{newKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewKey(null)}
|
||||
className="mt-2 block text-xs text-amber-700 hover:text-amber-900"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key name (e.g., claude-desktop)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createKey.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{keys && keys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-gray-900">{key.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
{key.keyPrefix}...
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteKey.mutate(key.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportExportSection() {
|
||||
const exportItems = useExportItems();
|
||||
const importItems = useImportItems();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
imported: number;
|
||||
createdCategories: string[];
|
||||
errors: string[];
|
||||
} | null>(null);
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImportResult(null);
|
||||
try {
|
||||
const result = await importItems.mutateAsync(file);
|
||||
setImportResult(result);
|
||||
} catch (err) {
|
||||
setImportResult({
|
||||
imported: 0,
|
||||
createdCategories: [],
|
||||
errors: [(err as Error).message],
|
||||
});
|
||||
}
|
||||
// Reset so the same file can be imported again if needed
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Export your gear collection as a CSV file, or import items from a CSV.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportItems}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
|
||||
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
|
||||
{importItems.isPending ? "Importing..." : "Import CSV"}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
disabled={importItems.isPending}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-xs space-y-1 ${
|
||||
importResult.errors.length > 0 && importResult.imported === 0
|
||||
? "bg-red-50 border border-red-200 text-red-700"
|
||||
: "bg-green-50 border border-green-200 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{importResult.imported > 0 && (
|
||||
<p className="font-medium">
|
||||
{importResult.imported} item
|
||||
{importResult.imported !== 1 ? "s" : ""} imported.
|
||||
</p>
|
||||
)}
|
||||
{importResult.createdCategories.length > 0 && (
|
||||
<p>New categories: {importResult.createdCategories.join(", ")}</p>
|
||||
)}
|
||||
{importResult.errors.map((err, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||
<p key={i} className="text-red-600">
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
{importResult.imported === 0 && importResult.errors.length === 0 && (
|
||||
<p>No items found in the CSV.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const updateSetting = useUpdateSetting();
|
||||
const { data: auth } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
@@ -99,6 +344,18 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||
<ImportExportSection />
|
||||
</div>
|
||||
|
||||
{auth?.user && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||
<ChangePasswordSection />
|
||||
<div className="border-t border-gray-100" />
|
||||
<ApiKeySection />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,13 @@ import { CategoryHeader } from "../../components/CategoryHeader";
|
||||
import { ItemCard } from "../../components/ItemCard";
|
||||
import { ItemPicker } from "../../components/ItemPicker";
|
||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||
import { useFormatters } from "../../hooks/useFormatters";
|
||||
import {
|
||||
useDeleteSetup,
|
||||
useRemoveSetupItem,
|
||||
useSetup,
|
||||
useUpdateItemClassification,
|
||||
} from "../../hooks/useSetups";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
|
||||
export const Route = createFileRoute("/setups/$setupId")({
|
||||
@@ -21,8 +19,7 @@ export const Route = createFileRoute("/setups/$setupId")({
|
||||
|
||||
function SetupDetailPage() {
|
||||
const { setupId } = Route.useParams();
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const { weight, price } = useFormatters();
|
||||
const navigate = useNavigate();
|
||||
const numericId = Number(setupId);
|
||||
const { data: setup, isLoading } = useSetup(numericId);
|
||||
@@ -56,13 +53,13 @@ function SetupDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Compute totals from items
|
||||
// Compute totals from items (multiply by quantity)
|
||||
const totalWeight = setup.items.reduce(
|
||||
(sum, item) => sum + (item.weightGrams ?? 0),
|
||||
(sum, item) => sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||
0,
|
||||
);
|
||||
const totalCost = setup.items.reduce(
|
||||
(sum, item) => sum + (item.priceCents ?? 0),
|
||||
(sum, item) => sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
|
||||
0,
|
||||
);
|
||||
const itemCount = setup.items.length;
|
||||
@@ -127,13 +124,13 @@ function SetupDetailPage() {
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium text-gray-700">
|
||||
{formatWeight(totalWeight, unit)}
|
||||
{weight(totalWeight)}
|
||||
</span>{" "}
|
||||
total
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium text-gray-700">
|
||||
{formatPrice(totalCost, currency)}
|
||||
{price(totalCost)}
|
||||
</span>{" "}
|
||||
cost
|
||||
</span>
|
||||
@@ -210,11 +207,13 @@ function SetupDetailPage() {
|
||||
{ items: categoryItems, categoryName, categoryIcon },
|
||||
]) => {
|
||||
const catWeight = categoryItems.reduce(
|
||||
(sum, item) => sum + (item.weightGrams ?? 0),
|
||||
(sum, item) =>
|
||||
sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
|
||||
0,
|
||||
);
|
||||
const catCost = categoryItems.reduce(
|
||||
(sum, item) => sum + (item.priceCents ?? 0),
|
||||
(sum, item) =>
|
||||
sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
@@ -235,6 +234,7 @@ function SetupDetailPage() {
|
||||
name={item.name}
|
||||
weightGrams={item.weightGrams}
|
||||
priceCents={item.priceCents}
|
||||
quantity={item.quantity}
|
||||
categoryName={categoryName}
|
||||
categoryIcon={categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useEffect, useState } from "react";
|
||||
import { CandidateCard } from "../../components/CandidateCard";
|
||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||
import { SetupImpactSelector } from "../../components/SetupImpactSelector";
|
||||
import {
|
||||
useReorderCandidates,
|
||||
useUpdateCandidate,
|
||||
} from "../../hooks/useCandidates";
|
||||
import { useImpactDeltas } from "../../hooks/useImpactDeltas";
|
||||
import { useSetup } from "../../hooks/useSetups";
|
||||
import { useThread } from "../../hooks/useThreads";
|
||||
import { LucideIcon } from "../../lib/iconData";
|
||||
import { useUIStore } from "../../stores/uiStore";
|
||||
@@ -23,15 +26,21 @@ function ThreadDetailPage() {
|
||||
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
||||
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
||||
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||
const updateCandidate = useUpdateCandidate(threadId);
|
||||
const reorderMutation = useReorderCandidates(threadId);
|
||||
const { data: setupData } = useSetup(selectedSetupId);
|
||||
const { deltas } = useImpactDeltas(
|
||||
thread?.candidates ?? [],
|
||||
setupData?.items,
|
||||
thread?.categoryId ?? 0,
|
||||
);
|
||||
|
||||
const [tempItems, setTempItems] =
|
||||
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
||||
null,
|
||||
);
|
||||
const [tempItems, setTempItems] = useState<
|
||||
NonNullable<typeof thread>["candidates"] | null
|
||||
>(null);
|
||||
|
||||
// Clear tempItems when server data changes (biome-ignore: thread?.candidates is intentional dep)
|
||||
// Clear tempItems when server data changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
||||
useEffect(() => {
|
||||
setTempItems(null);
|
||||
@@ -78,10 +87,9 @@ function ThreadDetailPage() {
|
||||
|
||||
function handleDragEnd() {
|
||||
if (!tempItems) return;
|
||||
reorderMutation.mutate(
|
||||
{ orderedIds: tempItems.map((c) => c.id) },
|
||||
{ onSettled: () => setTempItems(null) },
|
||||
);
|
||||
reorderMutation.mutate({
|
||||
orderedIds: tempItems.map((c) => c.id),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -120,8 +128,8 @@ function ThreadDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Toolbar: Add candidate + view toggle */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
{isActive && candidateViewMode !== "compare" && (
|
||||
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCandidateAddPanel}
|
||||
@@ -185,6 +193,7 @@ function ThreadDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SetupImpactSelector threadStatus={thread.status} />
|
||||
</div>
|
||||
|
||||
{/* Candidates */}
|
||||
@@ -208,6 +217,7 @@ function ThreadDetailPage() {
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
deltas={deltas}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
isActive ? (
|
||||
@@ -215,8 +225,7 @@ function ThreadDetailPage() {
|
||||
axis="y"
|
||||
values={displayItems}
|
||||
onReorder={setTempItems}
|
||||
onPointerUp={handleDragEnd}
|
||||
className="flex flex-col gap-2"
|
||||
className="flex flex-col"
|
||||
>
|
||||
{displayItems.map((candidate, index) => (
|
||||
<CandidateListItem
|
||||
@@ -230,6 +239,8 @@ function ThreadDetailPage() {
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
delta={deltas[candidate.id]}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
@@ -247,6 +258,7 @@ function ThreadDetailPage() {
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
delta={deltas[candidate.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -276,6 +288,7 @@ function ThreadDetailPage() {
|
||||
pros={candidate.pros}
|
||||
cons={candidate.cons}
|
||||
rank={index + 1}
|
||||
delta={deltas[candidate.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,10 @@ interface UIState {
|
||||
// Candidate view mode
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
|
||||
// Setup impact preview
|
||||
selectedSetupId: number | null;
|
||||
setSelectedSetupId: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
@@ -111,4 +115,8 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
// Candidate view mode
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
|
||||
// Setup impact preview
|
||||
selectedSetupId: null,
|
||||
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||
}));
|
||||
|
||||
@@ -20,6 +20,8 @@ export const items = sqliteTable("items", {
|
||||
notes: text("notes"),
|
||||
productUrl: text("product_url"),
|
||||
imageFilename: text("image_filename"),
|
||||
imageSourceUrl: text("image_source_url"),
|
||||
quantity: integer("quantity").notNull().default(1),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
@@ -58,6 +60,7 @@ export const threadCandidates = sqliteTable("thread_candidates", {
|
||||
notes: text("notes"),
|
||||
productUrl: text("product_url"),
|
||||
imageFilename: text("image_filename"),
|
||||
imageSourceUrl: text("image_source_url"),
|
||||
status: text("status").notNull().default("researching"),
|
||||
pros: text("pros"),
|
||||
cons: text("cons"),
|
||||
@@ -96,3 +99,30 @@ export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
keyHash: text("key_hash").notNull(),
|
||||
keyPrefix: text("key_prefix").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { db as prodDb } from "../db/index.ts";
|
||||
import { seedDefaults } from "../db/seed.ts";
|
||||
import { mcpRoutes } from "./mcp/index.ts";
|
||||
import { requireAuth } from "./middleware/auth.ts";
|
||||
import { authRoutes } from "./routes/auth.ts";
|
||||
import { categoryRoutes } from "./routes/categories.ts";
|
||||
import { imageRoutes } from "./routes/images.ts";
|
||||
import { itemRoutes } from "./routes/items.ts";
|
||||
@@ -14,12 +18,39 @@ seedDefaults();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Centralized error handler
|
||||
app.onError((err, c) => {
|
||||
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||
const message =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "Internal server error"
|
||||
: err.message || "Internal server error";
|
||||
return c.json({ error: message }, 500);
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get("/api/health", (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// Inject production database into request context
|
||||
app.use("/api/*", async (c, next) => {
|
||||
c.set("db", prodDb);
|
||||
return next();
|
||||
});
|
||||
|
||||
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||
app.use("/api/*", async (c, next) => {
|
||||
// Skip auth routes — they handle their own auth
|
||||
if (c.req.path.startsWith("/api/auth")) return next();
|
||||
// Skip GET requests — read is public
|
||||
if (c.req.method === "GET") return next();
|
||||
// All other methods require auth
|
||||
return requireAuth(c, next);
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.route("/api/auth", authRoutes);
|
||||
app.route("/api/items", itemRoutes);
|
||||
app.route("/api/categories", categoryRoutes);
|
||||
app.route("/api/totals", totalRoutes);
|
||||
@@ -28,6 +59,11 @@ app.route("/api/settings", settingsRoutes);
|
||||
app.route("/api/threads", threadRoutes);
|
||||
app.route("/api/setups", setupRoutes);
|
||||
|
||||
// MCP server (conditionally mounted)
|
||||
if (process.env.GEARBOX_MCP !== "false") {
|
||||
app.route("/mcp", mcpRoutes);
|
||||
}
|
||||
|
||||
// Serve uploaded images
|
||||
app.use("/uploads/*", serveStatic({ root: "./" }));
|
||||
|
||||
|
||||
9
src/server/lib/params.ts
Normal file
9
src/server/lib/params.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Parse a route parameter as a positive integer ID.
|
||||
* Returns the number if valid, or null if the string is not a positive integer.
|
||||
*/
|
||||
export function parseId(raw: string): number | null {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return null;
|
||||
return id;
|
||||
}
|
||||
174
src/server/mcp/index.ts
Normal file
174
src/server/mcp/index.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { Hono } from "hono";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { getUserCount, verifyApiKey } from "../services/auth.service.ts";
|
||||
import { getCollectionSummary } from "./resources/collection.ts";
|
||||
import {
|
||||
categoryToolDefinitions,
|
||||
registerCategoryTools,
|
||||
} from "./tools/categories.ts";
|
||||
import { imageToolDefinitions, registerImageTools } from "./tools/images.ts";
|
||||
import { itemToolDefinitions, registerItemTools } from "./tools/items.ts";
|
||||
import { registerSetupTools, setupToolDefinitions } from "./tools/setups.ts";
|
||||
import { registerThreadTools, threadToolDefinitions } from "./tools/threads.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
function createMcpServer(db: Db): McpServer {
|
||||
const server = new McpServer({ name: "GearBox", version: "1.0.0" });
|
||||
|
||||
// Register item tools
|
||||
const itemHandlers = registerItemTools(db);
|
||||
for (const def of itemToolDefinitions) {
|
||||
const handler = itemHandlers[def.name as keyof typeof itemHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register category tools
|
||||
const categoryHandlers = registerCategoryTools(db);
|
||||
for (const def of categoryToolDefinitions) {
|
||||
const handler = categoryHandlers[def.name as keyof typeof categoryHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register thread tools
|
||||
const threadHandlers = registerThreadTools(db);
|
||||
for (const def of threadToolDefinitions) {
|
||||
const handler = threadHandlers[def.name as keyof typeof threadHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register setup tools
|
||||
const setupHandlers = registerSetupTools(db);
|
||||
for (const def of setupToolDefinitions) {
|
||||
const handler = setupHandlers[def.name as keyof typeof setupHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register image tools
|
||||
const imageHandlers = registerImageTools();
|
||||
for (const def of imageToolDefinitions) {
|
||||
const handler = imageHandlers[def.name as keyof typeof imageHandlers];
|
||||
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||
}
|
||||
|
||||
// Register collection summary resource
|
||||
server.resource(
|
||||
"collection-summary",
|
||||
"gearbox://collection/summary",
|
||||
{
|
||||
description:
|
||||
"Overview of the entire gear collection including totals, categories, and active research threads.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
async () => {
|
||||
const summary = getCollectionSummary(db);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: "gearbox://collection/summary",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(summary, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// Store active transports by session ID
|
||||
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
|
||||
export const mcpRoutes = new Hono();
|
||||
|
||||
// Auth middleware for all MCP requests
|
||||
mcpRoutes.use("/*", async (c, next) => {
|
||||
const db = c.get("db") ?? prodDb;
|
||||
const apiKey = c.req.header("X-API-Key");
|
||||
|
||||
// Require API key when auth is configured (users exist)
|
||||
if (getUserCount(db) > 0) {
|
||||
if (!apiKey) {
|
||||
return c.json({ error: "API key required" }, 401);
|
||||
}
|
||||
const valid = await verifyApiKey(db, apiKey);
|
||||
if (!valid) {
|
||||
return c.json({ error: "Invalid API key" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
mcpRoutes.post("/", async (c) => {
|
||||
const db = c.get("db") ?? prodDb;
|
||||
|
||||
// Check for existing session
|
||||
const sessionId = c.req.header("mcp-session-id");
|
||||
|
||||
if (sessionId) {
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
return response;
|
||||
}
|
||||
|
||||
// New session: create transport and MCP server
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (newSessionId) => {
|
||||
transports.set(newSessionId, transport);
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up on close
|
||||
transport.onclose = () => {
|
||||
const sid = [...transports.entries()].find(
|
||||
([_, t]) => t === transport,
|
||||
)?.[0];
|
||||
if (sid) transports.delete(sid);
|
||||
};
|
||||
|
||||
const server = createMcpServer(db);
|
||||
await server.connect(transport);
|
||||
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
return response;
|
||||
});
|
||||
|
||||
mcpRoutes.get("/", async (c) => {
|
||||
const sessionId = c.req.header("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Session ID required" }, 400);
|
||||
}
|
||||
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
return response;
|
||||
});
|
||||
|
||||
mcpRoutes.delete("/", async (c) => {
|
||||
const sessionId = c.req.header("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Session ID required" }, 400);
|
||||
}
|
||||
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
await transport.close();
|
||||
transports.delete(sessionId);
|
||||
return c.text("", 200);
|
||||
});
|
||||
41
src/server/mcp/resources/collection.ts
Normal file
41
src/server/mcp/resources/collection.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { db as prodDb } from "../../../db/index.ts";
|
||||
import { getAllCategories } from "../../services/category.service.ts";
|
||||
import { getAllItems } from "../../services/item.service.ts";
|
||||
import { getAllSetups } from "../../services/setup.service.ts";
|
||||
import { getAllThreads } from "../../services/thread.service.ts";
|
||||
import { getGlobalTotals } from "../../services/totals.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getCollectionSummary(db: Db) {
|
||||
const totals = getGlobalTotals(db);
|
||||
const categories = getAllCategories(db);
|
||||
const items = getAllItems(db);
|
||||
const setups = getAllSetups(db);
|
||||
const activeThreads = getAllThreads(db, false);
|
||||
|
||||
// Build items-by-category map
|
||||
const itemsByCategory: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
const catName = item.categoryName;
|
||||
itemsByCategory[catName] = (itemsByCategory[catName] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
overview: {
|
||||
totalItems: totals?.itemCount ?? 0,
|
||||
totalWeightGrams: totals?.totalWeight ?? 0,
|
||||
totalCostCents: totals?.totalCost ?? 0,
|
||||
categoryCount: categories.length,
|
||||
setupCount: setups.length,
|
||||
activeThreadCount: activeThreads.length,
|
||||
},
|
||||
itemsByCategory,
|
||||
activeThreads: activeThreads.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
candidateCount: t.candidateCount,
|
||||
category: t.categoryName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
72
src/server/mcp/tools/categories.ts
Normal file
72
src/server/mcp/tools/categories.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { db as prodDb } from "../../../db/index.ts";
|
||||
import {
|
||||
createCategory,
|
||||
getAllCategories,
|
||||
} from "../../services/category.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const categoryToolDefinitions = [
|
||||
{
|
||||
name: "list_categories",
|
||||
description: "List all gear categories.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_category",
|
||||
description: "Create a new gear category.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string", description: "Category name" },
|
||||
icon: {
|
||||
type: "string",
|
||||
description: "Icon name (defaults to 'package')",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerCategoryTools(db: Db) {
|
||||
return {
|
||||
list_categories: async (): Promise<ToolResult> => {
|
||||
try {
|
||||
const cats = getAllCategories(db);
|
||||
return textResult(cats);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_category: async (args: {
|
||||
name: string;
|
||||
icon?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const cat = createCategory(db, args);
|
||||
return textResult(cat);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
48
src/server/mcp/tools/images.ts
Normal file
48
src/server/mcp/tools/images.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { fetchImageFromUrl } from "../../services/image.service.ts";
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const imageToolDefinitions = [
|
||||
{
|
||||
name: "upload_image_from_url",
|
||||
description:
|
||||
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
url: {
|
||||
type: "string",
|
||||
description: "URL of the image to fetch (jpeg, png, or webp)",
|
||||
},
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerImageTools() {
|
||||
return {
|
||||
upload_image_from_url: async (args: {
|
||||
url: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = await fetchImageFromUrl(args.url);
|
||||
return textResult(result);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
190
src/server/mcp/tools/items.ts
Normal file
190
src/server/mcp/tools/items.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { db as prodDb } from "../../../db/index.ts";
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
getAllItems,
|
||||
getItemById,
|
||||
updateItem,
|
||||
} from "../../services/item.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const itemToolDefinitions = [
|
||||
{
|
||||
name: "list_items",
|
||||
description:
|
||||
"List all items in the gear collection, optionally filtered by category.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
categoryId: {
|
||||
type: "number",
|
||||
description: "Filter items by category ID",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_item",
|
||||
description: "Get a single item by its ID, including all details.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "The item ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_item",
|
||||
description:
|
||||
"Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string", description: "Item name" },
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
weightGrams: { type: "number", description: "Weight in grams" },
|
||||
priceCents: { type: "number", description: "Price in cents" },
|
||||
notes: { type: "string", description: "Notes about the item" },
|
||||
productUrl: { type: "string", description: "URL to the product page" },
|
||||
imageFilename: {
|
||||
type: "string",
|
||||
description: "Filename of an uploaded image",
|
||||
},
|
||||
imageSourceUrl: {
|
||||
type: "string",
|
||||
description: "Original URL the image was fetched from",
|
||||
},
|
||||
},
|
||||
required: ["name", "categoryId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_item",
|
||||
description: "Update an existing item's fields.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "The item ID to update" },
|
||||
name: { type: "string", description: "Item name" },
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
weightGrams: { type: "number", description: "Weight in grams" },
|
||||
priceCents: { type: "number", description: "Price in cents" },
|
||||
notes: { type: "string", description: "Notes about the item" },
|
||||
productUrl: { type: "string", description: "URL to the product page" },
|
||||
imageFilename: {
|
||||
type: "string",
|
||||
description: "Filename of an uploaded image",
|
||||
},
|
||||
imageSourceUrl: {
|
||||
type: "string",
|
||||
description: "Original URL the image was fetched from",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete_item",
|
||||
description: "Delete an item from the gear collection by ID.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "The item ID to delete" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerItemTools(db: Db) {
|
||||
return {
|
||||
list_items: async (args: { categoryId?: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const items = getAllItems(db);
|
||||
if (args.categoryId) {
|
||||
return textResult(
|
||||
items.filter((i) => i.categoryId === args.categoryId),
|
||||
);
|
||||
}
|
||||
return textResult(items);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
get_item: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const item = getItemById(db, args.id);
|
||||
if (!item) return errorResult(`Item ${args.id} not found`);
|
||||
return textResult(item);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_item: async (args: {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const item = createItem(db, args);
|
||||
return textResult(item);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
update_item: async (args: {
|
||||
id: number;
|
||||
name?: string;
|
||||
categoryId?: number;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const { id, ...data } = args;
|
||||
const item = updateItem(db, id, data);
|
||||
if (!item) return errorResult(`Item ${id} not found`);
|
||||
return textResult(item);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
delete_item: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const item = deleteItem(db, args.id);
|
||||
if (!item) return errorResult(`Item ${args.id} not found`);
|
||||
return textResult({ deleted: true, item });
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
131
src/server/mcp/tools/setups.ts
Normal file
131
src/server/mcp/tools/setups.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { db as prodDb } from "../../../db/index.ts";
|
||||
import {
|
||||
createSetup,
|
||||
getAllSetups,
|
||||
getSetupWithItems,
|
||||
syncSetupItems,
|
||||
updateSetup,
|
||||
} from "../../services/setup.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const setupToolDefinitions = [
|
||||
{
|
||||
name: "list_setups",
|
||||
description:
|
||||
"List all gear setups with item counts and weight/cost totals.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_setup",
|
||||
description: "Get a setup with all its items and details.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Setup ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_setup",
|
||||
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string", description: "Setup name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_setup",
|
||||
description:
|
||||
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Setup ID" },
|
||||
name: { type: "string", description: "New setup name" },
|
||||
itemIds: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
description: "Array of item IDs to include in the setup",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerSetupTools(db: Db) {
|
||||
return {
|
||||
list_setups: async (): Promise<ToolResult> => {
|
||||
try {
|
||||
const setupList = getAllSetups(db);
|
||||
return textResult(setupList);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
get_setup: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const setup = getSetupWithItems(db, args.id);
|
||||
if (!setup) return errorResult(`Setup ${args.id} not found`);
|
||||
return textResult(setup);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_setup: async (args: { name: string }): Promise<ToolResult> => {
|
||||
try {
|
||||
const setup = createSetup(db, args);
|
||||
return textResult(setup);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
update_setup: async (args: {
|
||||
id: number;
|
||||
name?: string;
|
||||
itemIds?: number[];
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
let setup = null;
|
||||
if (args.name) {
|
||||
setup = updateSetup(db, args.id, { name: args.name });
|
||||
if (!setup) return errorResult(`Setup ${args.id} not found`);
|
||||
}
|
||||
if (args.itemIds) {
|
||||
syncSetupItems(db, args.id, args.itemIds);
|
||||
}
|
||||
// Return updated setup with items
|
||||
const result = getSetupWithItems(db, args.id);
|
||||
if (!result) return errorResult(`Setup ${args.id} not found`);
|
||||
return textResult(result);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
252
src/server/mcp/tools/threads.ts
Normal file
252
src/server/mcp/tools/threads.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { db as prodDb } from "../../../db/index.ts";
|
||||
import {
|
||||
createCandidate,
|
||||
createThread,
|
||||
deleteCandidate,
|
||||
getAllThreads,
|
||||
getThreadWithCandidates,
|
||||
resolveThread,
|
||||
updateCandidate,
|
||||
} from "../../services/thread.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
function textResult(data: unknown): ToolResult {
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
function errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
||||
};
|
||||
}
|
||||
|
||||
export const threadToolDefinitions = [
|
||||
{
|
||||
name: "list_threads",
|
||||
description:
|
||||
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
includeResolved: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Include resolved threads (default: false, only active threads)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_thread",
|
||||
description:
|
||||
"Get a thread with all its candidates for detailed comparison.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Thread ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_thread",
|
||||
description:
|
||||
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Thread name (e.g. 'Handlebar bag')",
|
||||
},
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
},
|
||||
required: ["name", "categoryId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolve_thread",
|
||||
description:
|
||||
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
threadId: { type: "number", description: "Thread ID" },
|
||||
candidateId: {
|
||||
type: "number",
|
||||
description: "ID of the winning candidate",
|
||||
},
|
||||
},
|
||||
required: ["threadId", "candidateId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add_candidate",
|
||||
description: "Add a candidate option to a research thread for comparison.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
threadId: { type: "number", description: "Thread ID" },
|
||||
name: { type: "string", description: "Candidate name" },
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
weightGrams: { type: "number", description: "Weight in grams" },
|
||||
priceCents: { type: "number", description: "Price in cents" },
|
||||
notes: { type: "string", description: "Notes" },
|
||||
productUrl: { type: "string", description: "Product URL" },
|
||||
imageFilename: { type: "string", description: "Image filename" },
|
||||
pros: { type: "string", description: "Pros of this candidate" },
|
||||
cons: { type: "string", description: "Cons of this candidate" },
|
||||
},
|
||||
required: ["threadId", "name", "categoryId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_candidate",
|
||||
description:
|
||||
"Update a candidate's details (name, price, pros, cons, etc.).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Candidate ID" },
|
||||
name: { type: "string", description: "Candidate name" },
|
||||
weightGrams: { type: "number", description: "Weight in grams" },
|
||||
priceCents: { type: "number", description: "Price in cents" },
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
notes: { type: "string", description: "Notes" },
|
||||
productUrl: { type: "string", description: "Product URL" },
|
||||
imageFilename: { type: "string", description: "Image filename" },
|
||||
imageSourceUrl: { type: "string", description: "Image source URL" },
|
||||
status: {
|
||||
type: "string",
|
||||
description: "Status: researching, ordered, or arrived",
|
||||
},
|
||||
pros: { type: "string", description: "Pros" },
|
||||
cons: { type: "string", description: "Cons" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_candidate",
|
||||
description: "Remove a candidate from a research thread.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Candidate ID to remove" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function registerThreadTools(db: Db) {
|
||||
return {
|
||||
list_threads: async (args: {
|
||||
includeResolved?: boolean;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const threadList = getAllThreads(db, args.includeResolved ?? false);
|
||||
return textResult(threadList);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
get_thread: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const thread = getThreadWithCandidates(db, args.id);
|
||||
if (!thread) return errorResult(`Thread ${args.id} not found`);
|
||||
return textResult(thread);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
create_thread: async (args: {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const thread = createThread(db, args);
|
||||
return textResult(thread);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
resolve_thread: async (args: {
|
||||
threadId: number;
|
||||
candidateId: number;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = resolveThread(db, args.threadId, args.candidateId);
|
||||
if (!result.success) {
|
||||
return errorResult(result.error ?? "Failed to resolve thread");
|
||||
}
|
||||
return textResult(result);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
add_candidate: async (args: {
|
||||
threadId: number;
|
||||
name: string;
|
||||
categoryId: number;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
pros?: string;
|
||||
cons?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const { threadId, ...data } = args;
|
||||
const candidate = createCandidate(db, threadId, data);
|
||||
return textResult(candidate);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
update_candidate: async (args: {
|
||||
id: number;
|
||||
name?: string;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
categoryId?: number;
|
||||
notes?: string;
|
||||
productUrl?: string;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
status?: "researching" | "ordered" | "arrived";
|
||||
pros?: string;
|
||||
cons?: string;
|
||||
}): Promise<ToolResult> => {
|
||||
try {
|
||||
const { id, ...data } = args;
|
||||
const candidate = updateCandidate(db, id, data);
|
||||
if (!candidate) return errorResult(`Candidate ${id} not found`);
|
||||
return textResult(candidate);
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
remove_candidate: async (args: { id: number }): Promise<ToolResult> => {
|
||||
try {
|
||||
const candidate = deleteCandidate(db, args.id);
|
||||
if (!candidate) return errorResult(`Candidate ${args.id} not found`);
|
||||
return textResult({ deleted: true, candidate });
|
||||
} catch (err) {
|
||||
return errorResult((err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
38
src/server/middleware/auth.ts
Normal file
38
src/server/middleware/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import {
|
||||
getSession,
|
||||
getUserCount,
|
||||
refreshSession,
|
||||
verifyApiKey,
|
||||
} from "../services/auth.service";
|
||||
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
const db = c.get("db");
|
||||
|
||||
// Check if any users exist at all
|
||||
if (getUserCount(db) === 0) {
|
||||
return c.json({ error: "setup_required" }, 403);
|
||||
}
|
||||
|
||||
// Check API key first
|
||||
const apiKey = c.req.header("X-API-Key");
|
||||
if (apiKey) {
|
||||
const valid = await verifyApiKey(db, apiKey);
|
||||
if (valid) return next();
|
||||
return c.json({ error: "Invalid API key" }, 401);
|
||||
}
|
||||
|
||||
// Check session cookie
|
||||
const sessionId = getCookie(c, "gearbox_session");
|
||||
if (sessionId) {
|
||||
const session = getSession(db, sessionId);
|
||||
if (session) {
|
||||
// Refresh session expiry on use
|
||||
refreshSession(db, sessionId);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
53
src/server/middleware/rateLimit.ts
Normal file
53
src/server/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
function getClientIp(c: Context): string {
|
||||
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now >= entry.resetAt) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function rateLimit(c: Context, next: Next) {
|
||||
cleanup();
|
||||
|
||||
const ip = getClientIp(c);
|
||||
const key = `${ip}:${c.req.path}`;
|
||||
const now = Date.now();
|
||||
|
||||
const entry = store.get(key);
|
||||
|
||||
if (!entry || now >= entry.resetAt) {
|
||||
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return next();
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_ATTEMPTS) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return next();
|
||||
}
|
||||
|
||||
/** @internal — only for testing */
|
||||
export function _resetForTesting() {
|
||||
store.clear();
|
||||
}
|
||||
197
src/server/routes/auth.ts
Normal file
197
src/server/routes/auth.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import { z } from "zod";
|
||||
import { users } from "../../db/schema.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import { requireAuth } from "../middleware/auth.ts";
|
||||
import { rateLimit } from "../middleware/rateLimit.ts";
|
||||
import {
|
||||
changePassword,
|
||||
createApiKey,
|
||||
createSession,
|
||||
createUser,
|
||||
deleteApiKey,
|
||||
deleteSession,
|
||||
getSession,
|
||||
getUserCount,
|
||||
listApiKeys,
|
||||
verifyPassword,
|
||||
} from "../services/auth.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
const setupSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(6),
|
||||
});
|
||||
const createKeySchema = z.object({ name: z.string().min(1) });
|
||||
|
||||
const COOKIE_NAME = "gearbox_session";
|
||||
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
// ── Public routes ───────────────────────────────────────────────────
|
||||
|
||||
app.get("/me", (c) => {
|
||||
const db = c.get("db");
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (sessionId) {
|
||||
const session = getSession(db, sessionId);
|
||||
if (session) {
|
||||
return c.json({
|
||||
user: { id: session.userId },
|
||||
setupRequired: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const setupRequired = getUserCount(db) === 0;
|
||||
return c.json({ user: null, setupRequired });
|
||||
});
|
||||
|
||||
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
|
||||
if (getUserCount(db) > 0) {
|
||||
return c.json({ error: "Setup already completed" }, 403);
|
||||
}
|
||||
|
||||
const { username, password } = c.req.valid("json");
|
||||
const user = await createUser(db, username, password);
|
||||
const session = createSession(db, user.id);
|
||||
|
||||
setCookie(c, COOKIE_NAME, session.id, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
});
|
||||
|
||||
return c.json({ username: user.username }, 201);
|
||||
});
|
||||
|
||||
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const { username, password } = c.req.valid("json");
|
||||
|
||||
const user = await verifyPassword(db, username, password);
|
||||
if (!user) {
|
||||
return c.json({ error: "Invalid credentials" }, 401);
|
||||
}
|
||||
|
||||
const session = createSession(db, user.id);
|
||||
|
||||
setCookie(c, COOKIE_NAME, session.id, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
});
|
||||
|
||||
return c.json({ username: user.username });
|
||||
});
|
||||
|
||||
app.post("/logout", (c) => {
|
||||
const db = c.get("db");
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (sessionId) {
|
||||
deleteSession(db, sessionId);
|
||||
}
|
||||
|
||||
deleteCookie(c, COOKIE_NAME, { path: "/" });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Protected routes ────────────────────────────────────────────────
|
||||
|
||||
app.put(
|
||||
"/password",
|
||||
requireAuth,
|
||||
zValidator("json", changePasswordSchema),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Session required for password change" }, 401);
|
||||
}
|
||||
const session = getSession(db, sessionId);
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Session required for password change" }, 401);
|
||||
}
|
||||
|
||||
const userRecord = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.get();
|
||||
|
||||
if (!userRecord) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = c.req.valid("json");
|
||||
const changed = await changePassword(
|
||||
db,
|
||||
userRecord.username,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
|
||||
if (!changed) {
|
||||
return c.json({ error: "Invalid current password" }, 401);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/keys", requireAuth, (c) => {
|
||||
const db = c.get("db");
|
||||
const keys = listApiKeys(db);
|
||||
return c.json(keys);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/keys",
|
||||
requireAuth,
|
||||
zValidator("json", createKeySchema),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const { name } = c.req.valid("json");
|
||||
const result = await createApiKey(db, name);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
key: result.rawKey,
|
||||
prefix: result.keyPrefix,
|
||||
},
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete("/keys/:id", requireAuth, (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid key ID" }, 400);
|
||||
deleteApiKey(db, id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export const authRoutes = app;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createCategorySchema,
|
||||
updateCategorySchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
@@ -33,7 +34,8 @@ app.put(
|
||||
zValidator("json", updateCategorySchema.omit({ id: true })),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const cat = updateCategory(db, id, data);
|
||||
if (!cat) return c.json({ error: "Category not found" }, 404);
|
||||
@@ -43,7 +45,8 @@ app.put(
|
||||
|
||||
app.delete("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||
const result = deleteCategory(db, id);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { fetchImageFromUrl } from "../services/image.service";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const fromUrlSchema = z.object({ url: z.string().url("Invalid URL") });
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/from-url", zValidator("json", fromUrlSchema), async (c) => {
|
||||
const { url } = c.req.valid("json");
|
||||
try {
|
||||
const result = await fetchImageFromUrl(url);
|
||||
return c.json(result, 201);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
// Known validation errors from the service
|
||||
const isValidationError =
|
||||
message.startsWith("Invalid") ||
|
||||
message.startsWith("URL must") ||
|
||||
message.startsWith("File too") ||
|
||||
message.startsWith("HTTP ");
|
||||
return c.json({ error: message }, isValidationError ? 400 : 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.image;
|
||||
|
||||
@@ -3,9 +3,12 @@ import { join } from "node:path";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
duplicateItem,
|
||||
getAllItems,
|
||||
getItemById,
|
||||
updateItem,
|
||||
@@ -15,6 +18,27 @@ type Env = { Variables: { db?: any } };
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.get("/export", (c) => {
|
||||
const db = c.get("db");
|
||||
const csv = exportItemsCsv(db);
|
||||
c.header("Content-Type", "text/csv");
|
||||
c.header("Content-Disposition", 'attachment; filename="gearbox-export.csv"');
|
||||
return c.body(csv);
|
||||
});
|
||||
|
||||
app.post("/import", async (c) => {
|
||||
const db = c.get("db");
|
||||
const body = await c.req.parseBody();
|
||||
// Accept either "file" (direct) or "image" (via apiUpload helper)
|
||||
const file = body.file ?? body.image;
|
||||
if (!file || typeof file === "string") {
|
||||
return c.json({ error: "No CSV file provided" }, 400);
|
||||
}
|
||||
const content = await file.text();
|
||||
const result = importItemsCsv(db, content);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.get("/", (c) => {
|
||||
const db = c.get("db");
|
||||
const items = getAllItems(db);
|
||||
@@ -23,7 +47,8 @@ app.get("/", (c) => {
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const item = getItemById(db, id);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(item);
|
||||
@@ -41,7 +66,8 @@ app.put(
|
||||
zValidator("json", updateItemSchema.omit({ id: true })),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const item = updateItem(db, id, data);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
@@ -49,9 +75,19 @@ app.put(
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/:id/duplicate", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const newItem = duplicateItem(db, id);
|
||||
if (!newItem) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(newItem, 201);
|
||||
});
|
||||
|
||||
app.delete("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||
const deleted = deleteItem(db, id);
|
||||
if (!deleted) return c.json({ error: "Item not found" }, 404);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { settings } from "../../db/schema.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
@@ -8,7 +7,7 @@ type Env = { Variables: { db?: any } };
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.get("/:key", (c) => {
|
||||
const database = c.get("db") ?? prodDb;
|
||||
const database = c.get("db");
|
||||
const key = c.req.param("key");
|
||||
const row = database
|
||||
.select()
|
||||
@@ -20,7 +19,7 @@ app.get("/:key", (c) => {
|
||||
});
|
||||
|
||||
app.put("/:key", async (c) => {
|
||||
const database = c.get("db") ?? prodDb;
|
||||
const database = c.get("db");
|
||||
const key = c.req.param("key");
|
||||
const body = await c.req.json<{ value: string }>();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateClassificationSchema,
|
||||
updateSetupSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import {
|
||||
createSetup,
|
||||
deleteSetup,
|
||||
@@ -38,7 +39,8 @@ app.post("/", zValidator("json", createSetupSchema), (c) => {
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||
const setup = getSetupWithItems(db, id);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json(setup);
|
||||
@@ -46,7 +48,8 @@ app.get("/:id", (c) => {
|
||||
|
||||
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const setup = updateSetup(db, id, data);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
@@ -55,7 +58,8 @@ app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
|
||||
|
||||
app.delete("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||
const deleted = deleteSetup(db, id);
|
||||
if (!deleted) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
@@ -65,7 +69,8 @@ app.delete("/:id", (c) => {
|
||||
|
||||
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||
const { itemIds } = c.req.valid("json");
|
||||
|
||||
const setup = getSetupWithItems(db, id);
|
||||
@@ -80,8 +85,9 @@ app.patch(
|
||||
zValidator("json", updateClassificationSchema),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const setupId = Number(c.req.param("id"));
|
||||
const itemId = Number(c.req.param("itemId"));
|
||||
const setupId = parseId(c.req.param("id"));
|
||||
const itemId = parseId(c.req.param("itemId"));
|
||||
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||
const { classification } = c.req.valid("json");
|
||||
updateItemClassification(db, setupId, itemId, classification);
|
||||
return c.json({ success: true });
|
||||
@@ -90,8 +96,9 @@ app.patch(
|
||||
|
||||
app.delete("/:id/items/:itemId", (c) => {
|
||||
const db = c.get("db");
|
||||
const setupId = Number(c.req.param("id"));
|
||||
const itemId = Number(c.req.param("itemId"));
|
||||
const setupId = parseId(c.req.param("id"));
|
||||
const itemId = parseId(c.req.param("itemId"));
|
||||
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||
removeSetupItem(db, setupId, itemId);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
updateCandidateSchema,
|
||||
updateThreadSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import {
|
||||
createCandidate,
|
||||
createThread,
|
||||
@@ -45,7 +46,8 @@ app.post("/", zValidator("json", createThreadSchema), (c) => {
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
|
||||
const thread = getThreadWithCandidates(db, id);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
return c.json(thread);
|
||||
@@ -53,7 +55,8 @@ app.get("/:id", (c) => {
|
||||
|
||||
app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const thread = updateThread(db, id, data);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
@@ -62,7 +65,8 @@ app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
|
||||
|
||||
app.delete("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
|
||||
const deleted = deleteThread(db, id);
|
||||
if (!deleted) return c.json({ error: "Thread not found" }, 404);
|
||||
|
||||
@@ -82,7 +86,8 @@ app.delete("/:id", async (c) => {
|
||||
|
||||
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const threadId = Number(c.req.param("id"));
|
||||
const threadId = parseId(c.req.param("id"));
|
||||
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
|
||||
|
||||
// Verify thread exists
|
||||
const thread = getThreadWithCandidates(db, threadId);
|
||||
@@ -98,7 +103,8 @@ app.put(
|
||||
zValidator("json", updateCandidateSchema),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const candidateId = Number(c.req.param("candidateId"));
|
||||
const candidateId = parseId(c.req.param("candidateId"));
|
||||
if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
const candidate = updateCandidate(db, candidateId, data);
|
||||
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
|
||||
@@ -108,7 +114,8 @@ app.put(
|
||||
|
||||
app.delete("/:threadId/candidates/:candidateId", async (c) => {
|
||||
const db = c.get("db");
|
||||
const candidateId = Number(c.req.param("candidateId"));
|
||||
const candidateId = parseId(c.req.param("candidateId"));
|
||||
if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400);
|
||||
const deleted = deleteCandidate(db, candidateId);
|
||||
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
|
||||
|
||||
@@ -131,7 +138,8 @@ app.patch(
|
||||
zValidator("json", reorderCandidatesSchema),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const threadId = Number(c.req.param("id"));
|
||||
const threadId = parseId(c.req.param("id"));
|
||||
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
|
||||
const { orderedIds } = c.req.valid("json");
|
||||
const result = reorderCandidates(db, threadId, orderedIds);
|
||||
if (!result.success) return c.json({ error: result.error }, 400);
|
||||
@@ -143,7 +151,8 @@ app.patch(
|
||||
|
||||
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const threadId = Number(c.req.param("id"));
|
||||
const threadId = parseId(c.req.param("id"));
|
||||
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
|
||||
const { candidateId } = c.req.valid("json");
|
||||
|
||||
const result = resolveThread(db, threadId, candidateId);
|
||||
|
||||
158
src/server/services/auth.service.ts
Normal file
158
src/server/services/auth.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { apiKeys, sessions, users } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
// ── User Management ──────────────────────────────────────────────────
|
||||
|
||||
export async function createUser(
|
||||
db: Db = prodDb,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const passwordHash = await Bun.password.hash(password);
|
||||
return db.insert(users).values({ username, passwordHash }).returning().get();
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
db: Db = prodDb,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const user = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.get();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const valid = await Bun.password.verify(password, user.passwordHash);
|
||||
return valid ? user : null;
|
||||
}
|
||||
|
||||
export function getUserCount(db: Db = prodDb): number {
|
||||
const result = db.select({ value: count() }).from(users).get();
|
||||
return result?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
db: Db = prodDb,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
const user = await verifyPassword(db, username, currentPassword);
|
||||
if (!user) return false;
|
||||
|
||||
const newHash = await Bun.password.hash(newPassword);
|
||||
db.update(users)
|
||||
.set({ passwordHash: newHash })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Session Management ───────────────────────────────────────────────
|
||||
|
||||
export function createSession(
|
||||
db: Db = prodDb,
|
||||
userId: number,
|
||||
expiryDays = 30,
|
||||
) {
|
||||
const id = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
return db
|
||||
.insert(sessions)
|
||||
.values({ id, userId, expiresAt })
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function getSession(db: Db = prodDb, sessionId: string) {
|
||||
const session = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.get();
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt < new Date()) {
|
||||
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function deleteSession(db: Db = prodDb, sessionId: string) {
|
||||
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
||||
}
|
||||
|
||||
export function refreshSession(
|
||||
db: Db = prodDb,
|
||||
sessionId: string,
|
||||
expiryDays = 30,
|
||||
) {
|
||||
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
db.update(sessions)
|
||||
.set({ expiresAt })
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.run();
|
||||
}
|
||||
|
||||
// ── API Key Management ───────────────────────────────────────────────
|
||||
|
||||
export async function createApiKey(db: Db = prodDb, name: string) {
|
||||
const rawKey = randomBytes(32).toString("hex");
|
||||
const keyHash = await Bun.password.hash(rawKey);
|
||||
const keyPrefix = rawKey.slice(0, 8);
|
||||
|
||||
const record = db
|
||||
.insert(apiKeys)
|
||||
.values({ name, keyHash, keyPrefix })
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
return { ...record, rawKey };
|
||||
}
|
||||
|
||||
export async function verifyApiKey(
|
||||
db: Db = prodDb,
|
||||
rawKey: string,
|
||||
): Promise<boolean> {
|
||||
const prefix = rawKey.slice(0, 8);
|
||||
const candidates = db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.keyPrefix, prefix))
|
||||
.all();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const valid = await Bun.password.verify(rawKey, candidate.keyHash);
|
||||
if (valid) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function listApiKeys(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function deleteApiKey(db: Db = prodDb, id: number) {
|
||||
db.delete(apiKeys).where(eq(apiKeys.id, id)).run();
|
||||
}
|
||||
246
src/server/services/csv.service.ts
Normal file
246
src/server/services/csv.service.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
// ─── CSV serialisation helpers ────────────────────────────────────────────────
|
||||
|
||||
function escapeField(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const str = String(value);
|
||||
// Wrap in quotes if the field contains a comma, double-quote, or newline
|
||||
if (
|
||||
str.includes(",") ||
|
||||
str.includes('"') ||
|
||||
str.includes("\n") ||
|
||||
str.includes("\r")
|
||||
) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function buildCsvRow(fields: (string | number | null | undefined)[]): string {
|
||||
return fields.map(escapeField).join(",");
|
||||
}
|
||||
|
||||
// ─── CSV parsing helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i <= line.length) {
|
||||
if (i === line.length) {
|
||||
// End of line — push empty trailing field only if we were expecting one
|
||||
// (handled by the loop condition above + break below)
|
||||
break;
|
||||
}
|
||||
|
||||
if (line[i] === '"') {
|
||||
// Quoted field
|
||||
let field = "";
|
||||
i++; // skip opening quote
|
||||
while (i < line.length) {
|
||||
if (line[i] === '"') {
|
||||
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||
// Escaped quote
|
||||
field += '"';
|
||||
i += 2;
|
||||
} else {
|
||||
// Closing quote
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
field += line[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
fields.push(field);
|
||||
// Skip comma separator
|
||||
if (i < line.length && line[i] === ",") i++;
|
||||
} else {
|
||||
// Unquoted field — read until comma or end of line
|
||||
const start = i;
|
||||
while (i < line.length && line[i] !== ",") i++;
|
||||
fields.push(line.slice(start, i));
|
||||
if (i < line.length) i++; // skip comma
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseCsv(content: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const nonEmpty = lines.filter((l) => l.trim() !== "");
|
||||
if (nonEmpty.length === 0) return { headers: [], rows: [] };
|
||||
const headers = parseCsvLine(nonEmpty[0]);
|
||||
const rows = nonEmpty.slice(1).map(parseCsvLine);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function exportItemsCsv(db: Db = prodDb): string {
|
||||
const rows = db
|
||||
.select({
|
||||
name: items.name,
|
||||
quantity: items.quantity,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryName: categories.name,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
})
|
||||
.from(items)
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.all();
|
||||
|
||||
const header =
|
||||
"name,quantity,weightGrams,priceCents,category,notes,productUrl";
|
||||
const dataLines = rows.map((row) =>
|
||||
buildCsvRow([
|
||||
row.name,
|
||||
row.quantity,
|
||||
row.weightGrams,
|
||||
row.priceCents,
|
||||
row.categoryName,
|
||||
row.notes,
|
||||
row.productUrl,
|
||||
]),
|
||||
);
|
||||
|
||||
return [header, ...dataLines].join("\n");
|
||||
}
|
||||
|
||||
// ─── Import ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
createdCategories: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function importItemsCsv(
|
||||
db: Db = prodDb,
|
||||
csvContent: string,
|
||||
): ImportResult {
|
||||
const { headers, rows } = parseCsv(csvContent);
|
||||
|
||||
const result: ImportResult = {
|
||||
imported: 0,
|
||||
createdCategories: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (headers.length === 0) return result;
|
||||
|
||||
// Normalise header names for lookup (case-insensitive)
|
||||
const headerIndex = (name: string): number =>
|
||||
headers.findIndex((h) => h.trim().toLowerCase() === name.toLowerCase());
|
||||
|
||||
const nameIdx = headerIndex("name");
|
||||
const quantityIdx = headerIndex("quantity");
|
||||
const weightIdx = headerIndex("weightGrams");
|
||||
const priceIdx = headerIndex("priceCents");
|
||||
const categoryIdx = headerIndex("category");
|
||||
const notesIdx = headerIndex("notes");
|
||||
const urlIdx = headerIndex("productUrl");
|
||||
|
||||
// Build a local category cache (name → id) seeded from the DB
|
||||
const categoryCache = new Map<string, number>();
|
||||
const existingCategories = db
|
||||
.select({ id: categories.id, name: categories.name })
|
||||
.from(categories)
|
||||
.all();
|
||||
for (const cat of existingCategories) {
|
||||
categoryCache.set(cat.name.toLowerCase(), cat.id);
|
||||
}
|
||||
|
||||
for (let rowNum = 0; rowNum < rows.length; rowNum++) {
|
||||
const row = rows[rowNum];
|
||||
const lineNum = rowNum + 2; // 1-based, +1 for header
|
||||
|
||||
try {
|
||||
const name = nameIdx >= 0 ? row[nameIdx]?.trim() : undefined;
|
||||
if (!name) {
|
||||
result.errors.push(`Row ${lineNum}: missing required field "name"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Category resolution
|
||||
let categoryId: number;
|
||||
const rawCategory = categoryIdx >= 0 ? row[categoryIdx]?.trim() : "";
|
||||
const categoryName = rawCategory || "Uncategorized";
|
||||
const cacheKey = categoryName.toLowerCase();
|
||||
|
||||
if (categoryCache.has(cacheKey)) {
|
||||
categoryId = categoryCache.get(cacheKey)!;
|
||||
} else {
|
||||
// Create a new category
|
||||
const inserted = db
|
||||
.insert(categories)
|
||||
.values({ name: categoryName, icon: "package" })
|
||||
.returning()
|
||||
.get();
|
||||
categoryId = inserted.id;
|
||||
categoryCache.set(cacheKey, categoryId);
|
||||
result.createdCategories.push(categoryName);
|
||||
}
|
||||
|
||||
// Parse optional numeric fields
|
||||
const rawQuantity = quantityIdx >= 0 ? row[quantityIdx]?.trim() : "";
|
||||
const quantity = rawQuantity ? Number.parseInt(rawQuantity, 10) : 1;
|
||||
if (rawQuantity && Number.isNaN(quantity)) {
|
||||
result.errors.push(
|
||||
`Row ${lineNum}: invalid quantity "${rawQuantity}", skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawWeight = weightIdx >= 0 ? row[weightIdx]?.trim() : "";
|
||||
const weightGrams = rawWeight ? Number.parseFloat(rawWeight) : null;
|
||||
if (rawWeight && Number.isNaN(weightGrams as number)) {
|
||||
result.errors.push(
|
||||
`Row ${lineNum}: invalid weightGrams "${rawWeight}", skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawPrice = priceIdx >= 0 ? row[priceIdx]?.trim() : "";
|
||||
const priceCents = rawPrice ? Number.parseInt(rawPrice, 10) : null;
|
||||
if (rawPrice && Number.isNaN(priceCents as number)) {
|
||||
result.errors.push(
|
||||
`Row ${lineNum}: invalid priceCents "${rawPrice}", skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const notes = notesIdx >= 0 ? row[notesIdx]?.trim() || null : null;
|
||||
const productUrl = urlIdx >= 0 ? row[urlIdx]?.trim() || null : null;
|
||||
|
||||
db.insert(items)
|
||||
.values({
|
||||
name,
|
||||
quantity,
|
||||
weightGrams,
|
||||
priceCents,
|
||||
categoryId,
|
||||
notes,
|
||||
productUrl,
|
||||
imageFilename: null,
|
||||
imageSourceUrl: null,
|
||||
})
|
||||
.run();
|
||||
|
||||
result.imported++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row ${lineNum}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
82
src/server/services/image.service.ts
Normal file
82
src/server/services/image.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const FETCH_TIMEOUT = 10_000; // 10 seconds
|
||||
|
||||
interface FetchImageResult {
|
||||
filename: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export async function fetchImageFromUrl(
|
||||
url: string,
|
||||
uploadsDir = "uploads",
|
||||
): Promise<FetchImageResult> {
|
||||
// Validate URL format
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error("Invalid URL format");
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error("URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
// Fetch with timeout
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
throw new Error("Request timed out");
|
||||
}
|
||||
throw new Error(`Failed to fetch image: ${(err as Error).message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: Failed to fetch image`);
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
const contentType = response.headers
|
||||
.get("content-type")
|
||||
?.split(";")[0]
|
||||
.trim();
|
||||
if (!contentType || !ALLOWED_TYPES.includes(contentType)) {
|
||||
throw new Error(
|
||||
`Invalid content type: ${contentType ?? "unknown"}. Accepted: jpeg, png, webp`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check content length if available
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
|
||||
throw new Error("File too large. Maximum size is 5MB");
|
||||
}
|
||||
|
||||
// Read body and check actual size
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_SIZE) {
|
||||
throw new Error("File too large. Maximum size is 5MB");
|
||||
}
|
||||
|
||||
// Determine extension
|
||||
const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1];
|
||||
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
|
||||
|
||||
// Ensure directory exists and write
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await Bun.write(join(uploadsDir, filename), buffer);
|
||||
|
||||
return { filename, sourceUrl: url };
|
||||
}
|
||||
@@ -12,10 +12,12 @@ export function getAllItems(db: Db = prodDb) {
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
quantity: items.quantity,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
imageSourceUrl: items.imageSourceUrl,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -38,6 +40,7 @@ export function getItemById(db: Db = prodDb, id: number) {
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
imageSourceUrl: items.imageSourceUrl,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
})
|
||||
@@ -61,10 +64,12 @@ export function createItem(
|
||||
name: data.name,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
quantity: data.quantity ?? 1,
|
||||
categoryId: data.categoryId,
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
imageSourceUrl: data.imageSourceUrl ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
@@ -77,10 +82,12 @@ export function updateItem(
|
||||
name: string;
|
||||
weightGrams: number;
|
||||
priceCents: number;
|
||||
quantity: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
imageSourceUrl: string;
|
||||
}>,
|
||||
) {
|
||||
// Check if item exists first
|
||||
@@ -100,6 +107,28 @@ export function updateItem(
|
||||
.get();
|
||||
}
|
||||
|
||||
export function duplicateItem(db: Db = prodDb, id: number) {
|
||||
const source = db.select().from(items).where(eq(items.id, id)).get();
|
||||
|
||||
if (!source) return null;
|
||||
|
||||
return db
|
||||
.insert(items)
|
||||
.values({
|
||||
name: `${source.name} (copy)`,
|
||||
weightGrams: source.weightGrams,
|
||||
priceCents: source.priceCents,
|
||||
categoryId: source.categoryId,
|
||||
notes: source.notes,
|
||||
productUrl: source.productUrl,
|
||||
imageFilename: source.imageFilename,
|
||||
imageSourceUrl: source.imageSourceUrl,
|
||||
quantity: source.quantity,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteItem(db: Db = prodDb, id: number) {
|
||||
// Get item first (for image cleanup info)
|
||||
const item = db.select().from(items).where(eq(items.id, id)).get();
|
||||
|
||||
@@ -21,12 +21,12 @@ export function getAllSetups(db: Db = prodDb) {
|
||||
WHERE setup_items.setup_id = setups.id
|
||||
), 0)`.as("item_count"),
|
||||
totalWeight: sql<number>`COALESCE((
|
||||
SELECT SUM(items.weight_grams) FROM setup_items
|
||||
SELECT SUM(items.weight_grams * items.quantity) FROM setup_items
|
||||
JOIN items ON items.id = setup_items.item_id
|
||||
WHERE setup_items.setup_id = setups.id
|
||||
), 0)`.as("total_weight"),
|
||||
totalCost: sql<number>`COALESCE((
|
||||
SELECT SUM(items.price_cents) FROM setup_items
|
||||
SELECT SUM(items.price_cents * items.quantity) FROM setup_items
|
||||
JOIN items ON items.id = setup_items.item_id
|
||||
WHERE setup_items.setup_id = setups.id
|
||||
), 0)`.as("total_cost"),
|
||||
@@ -45,6 +45,7 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
quantity: items.quantity,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
|
||||
@@ -76,6 +76,7 @@ export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
||||
notes: threadCandidates.notes,
|
||||
productUrl: threadCandidates.productUrl,
|
||||
imageFilename: threadCandidates.imageFilename,
|
||||
imageSourceUrl: threadCandidates.imageSourceUrl,
|
||||
status: threadCandidates.status,
|
||||
pros: threadCandidates.pros,
|
||||
cons: threadCandidates.cons,
|
||||
@@ -144,6 +145,7 @@ export function createCandidate(
|
||||
name: string;
|
||||
categoryId: number;
|
||||
imageFilename?: string;
|
||||
imageSourceUrl?: string;
|
||||
},
|
||||
) {
|
||||
const maxRow = db
|
||||
@@ -165,6 +167,7 @@ export function createCandidate(
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
imageSourceUrl: data.imageSourceUrl ?? null,
|
||||
status: data.status ?? "researching",
|
||||
pros: data.pros ?? null,
|
||||
cons: data.cons ?? null,
|
||||
@@ -185,6 +188,7 @@ export function updateCandidate(
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
imageSourceUrl: string;
|
||||
status: "researching" | "ordered" | "arrived";
|
||||
pros: string;
|
||||
cons: string;
|
||||
@@ -294,6 +298,8 @@ export function resolveThread(
|
||||
notes: candidate.notes,
|
||||
productUrl: candidate.productUrl,
|
||||
imageFilename: candidate.imageFilename,
|
||||
imageSourceUrl: candidate.imageSourceUrl,
|
||||
quantity: 1,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
@@ -10,8 +10,8 @@ export function getCategoryTotals(db: Db = prodDb) {
|
||||
categoryId: items.categoryId,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
@@ -23,8 +23,8 @@ export function getCategoryTotals(db: Db = prodDb) {
|
||||
export function getGlobalTotals(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams} * ${items.quantity}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents} * ${items.quantity}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
|
||||
@@ -8,6 +8,8 @@ export const createItemSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
productUrl: z.string().url().optional().or(z.literal("")),
|
||||
imageFilename: z.string().optional(),
|
||||
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
||||
quantity: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const updateItemSchema = createItemSchema.partial().extend({
|
||||
@@ -52,6 +54,7 @@ export const createCandidateSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
productUrl: z.string().url().optional().or(z.literal("")),
|
||||
imageFilename: z.string().optional(),
|
||||
imageSourceUrl: z.string().url().optional().or(z.literal("")),
|
||||
status: candidateStatusSchema.optional(),
|
||||
pros: z.string().optional(),
|
||||
cons: z.string().optional(),
|
||||
|
||||
@@ -1,95 +1,17 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import * as schema from "../../src/db/schema.ts";
|
||||
|
||||
export function createTestDb() {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.run("PRAGMA foreign_keys = ON");
|
||||
|
||||
// Create tables matching the Drizzle schema
|
||||
sqlite.run(`
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
icon TEXT NOT NULL DEFAULT 'package',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
weight_grams REAL,
|
||||
price_cents INTEGER,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id),
|
||||
notes TEXT,
|
||||
product_url TEXT,
|
||||
image_filename TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE threads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
resolved_candidate_id INTEGER,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id),
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE thread_candidates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
weight_grams REAL,
|
||||
price_cents INTEGER,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id),
|
||||
notes TEXT,
|
||||
product_url TEXT,
|
||||
image_filename TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'researching',
|
||||
pros TEXT,
|
||||
cons TEXT,
|
||||
sort_order REAL NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE setups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE setup_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
classification TEXT NOT NULL DEFAULT 'base'
|
||||
)
|
||||
`);
|
||||
|
||||
sqlite.run(`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
// Apply all migrations to create tables
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
|
||||
// Seed default Uncategorized category
|
||||
db.insert(schema.categories)
|
||||
.values({ name: "Uncategorized", icon: "package" })
|
||||
|
||||
87
tests/lib/impactDeltas.test.ts
Normal file
87
tests/lib/impactDeltas.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { computeImpactDeltas } from "../../src/client/lib/impactDeltas";
|
||||
|
||||
describe("computeImpactDeltas", () => {
|
||||
const candidate = { id: 1, weightGrams: 500, priceCents: 20000 };
|
||||
const candidate2 = { id: 2, weightGrams: 300, priceCents: 15000 };
|
||||
|
||||
it("returns mode 'none' when setupItems is undefined", () => {
|
||||
const result = computeImpactDeltas([candidate], undefined, 1);
|
||||
expect(result.mode).toBe("none");
|
||||
expect(Object.keys(result.deltas)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns replace mode when setup item matches thread category", () => {
|
||||
const setupItems = [
|
||||
{ categoryId: 5, weightGrams: 800, priceCents: 30000, name: "Old Tent" },
|
||||
];
|
||||
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||
expect(result.mode).toBe("replace");
|
||||
expect(result.deltas[1].weightDelta).toBe(-300); // 500 - 800
|
||||
expect(result.deltas[1].priceDelta).toBe(-10000); // 20000 - 30000
|
||||
expect(result.deltas[1].replacedItemName).toBe("Old Tent");
|
||||
});
|
||||
|
||||
it("returns add mode when no setup item matches thread category", () => {
|
||||
const setupItems = [
|
||||
{ categoryId: 99, weightGrams: 200, priceCents: 5000, name: "Unrelated" },
|
||||
];
|
||||
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||
expect(result.mode).toBe("add");
|
||||
expect(result.deltas[1].weightDelta).toBe(500);
|
||||
expect(result.deltas[1].priceDelta).toBe(20000);
|
||||
expect(result.deltas[1].replacedItemName).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null weightDelta when candidate weight is null", () => {
|
||||
const nullWeight = { id: 3, weightGrams: null, priceCents: 10000 };
|
||||
const setupItems = [
|
||||
{ categoryId: 5, weightGrams: 200, priceCents: 5000, name: "Item" },
|
||||
];
|
||||
const result = computeImpactDeltas([nullWeight], setupItems, 5);
|
||||
expect(result.deltas[3].weightDelta).toBeNull();
|
||||
expect(result.deltas[3].priceDelta).toBe(5000); // 10000 - 5000
|
||||
});
|
||||
|
||||
it("returns null priceDelta when candidate price is null", () => {
|
||||
const nullPrice = { id: 4, weightGrams: 500, priceCents: null };
|
||||
const setupItems = [
|
||||
{ categoryId: 5, weightGrams: 200, priceCents: 5000, name: "Item" },
|
||||
];
|
||||
const result = computeImpactDeltas([nullPrice], setupItems, 5);
|
||||
expect(result.deltas[4].weightDelta).toBe(300);
|
||||
expect(result.deltas[4].priceDelta).toBeNull();
|
||||
});
|
||||
|
||||
it("handles replace mode with null replaced item weight", () => {
|
||||
const setupItems = [
|
||||
{
|
||||
categoryId: 5,
|
||||
weightGrams: null,
|
||||
priceCents: 5000,
|
||||
name: "Unknown Weight",
|
||||
},
|
||||
];
|
||||
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||
expect(result.deltas[1].weightDelta).toBe(500); // treat as add for weight
|
||||
expect(result.deltas[1].priceDelta).toBe(15000); // 20000 - 5000
|
||||
});
|
||||
|
||||
it("shows negative delta when candidate is lighter", () => {
|
||||
const setupItems = [
|
||||
{ categoryId: 5, weightGrams: 1000, priceCents: 50000, name: "Heavy" },
|
||||
];
|
||||
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||
expect(result.deltas[1].weightDelta).toBe(-500);
|
||||
expect(result.deltas[1].priceDelta).toBe(-30000);
|
||||
});
|
||||
|
||||
it("handles multiple candidates", () => {
|
||||
const setupItems = [
|
||||
{ categoryId: 5, weightGrams: 400, priceCents: 18000, name: "Current" },
|
||||
];
|
||||
const result = computeImpactDeltas([candidate, candidate2], setupItems, 5);
|
||||
expect(result.deltas[1].weightDelta).toBe(100); // 500 - 400
|
||||
expect(result.deltas[2].weightDelta).toBe(-100); // 300 - 400
|
||||
});
|
||||
});
|
||||
37
tests/lib/params.test.ts
Normal file
37
tests/lib/params.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseId } from "../../src/server/lib/params";
|
||||
|
||||
describe("parseId", () => {
|
||||
it("returns number for valid positive integers", () => {
|
||||
expect(parseId("1")).toBe(1);
|
||||
expect(parseId("42")).toBe(42);
|
||||
expect(parseId("999")).toBe(999);
|
||||
});
|
||||
|
||||
it("returns null for zero", () => {
|
||||
expect(parseId("0")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for negative numbers", () => {
|
||||
expect(parseId("-1")).toBeNull();
|
||||
expect(parseId("-100")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for decimals", () => {
|
||||
expect(parseId("1.5")).toBeNull();
|
||||
expect(parseId("3.14")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-numeric strings", () => {
|
||||
expect(parseId("abc")).toBeNull();
|
||||
expect(parseId("")).toBeNull();
|
||||
expect(parseId("hello")).toBeNull();
|
||||
expect(parseId("12abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for special values", () => {
|
||||
expect(parseId("NaN")).toBeNull();
|
||||
expect(parseId("Infinity")).toBeNull();
|
||||
expect(parseId("-Infinity")).toBeNull();
|
||||
});
|
||||
});
|
||||
253
tests/mcp/tools.test.ts
Normal file
253
tests/mcp/tools.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
|
||||
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts";
|
||||
import { registerItemTools } from "../../src/server/mcp/tools/items.ts";
|
||||
import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts";
|
||||
import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
function parseResult(result: {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
}) {
|
||||
return JSON.parse(result.content[0].text);
|
||||
}
|
||||
|
||||
describe("MCP Item Tools", () => {
|
||||
test("list_items returns array", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const result = await tools.list_items({});
|
||||
const data = parseResult(result);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
|
||||
test("create_item creates and returns item", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const result = await tools.create_item({
|
||||
name: "Test Tent",
|
||||
categoryId: 1,
|
||||
weightGrams: 1200,
|
||||
priceCents: 35000,
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Test Tent");
|
||||
expect(data.weightGrams).toBe(1200);
|
||||
expect(data.priceCents).toBe(35000);
|
||||
expect(data.id).toBeDefined();
|
||||
});
|
||||
|
||||
test("get_item retrieves by ID", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const created = parseResult(
|
||||
await tools.create_item({ name: "Sleeping Bag", categoryId: 1 }),
|
||||
);
|
||||
const result = await tools.get_item({ id: created.id });
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Sleeping Bag");
|
||||
expect(data.id).toBe(created.id);
|
||||
});
|
||||
|
||||
test("get_item returns error for missing item", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const result = await tools.get_item({ id: 999 });
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain("not found");
|
||||
});
|
||||
|
||||
test("delete_item removes item", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerItemTools(db);
|
||||
const created = parseResult(
|
||||
await tools.create_item({ name: "To Delete", categoryId: 1 }),
|
||||
);
|
||||
const deleteResult = await tools.delete_item({ id: created.id });
|
||||
const data = parseResult(deleteResult);
|
||||
expect(data.deleted).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const getResult = await tools.get_item({ id: created.id });
|
||||
const getData = parseResult(getResult);
|
||||
expect(getData.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Category Tools", () => {
|
||||
test("list_categories returns array with Uncategorized", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerCategoryTools(db);
|
||||
const result = await tools.list_categories();
|
||||
const data = parseResult(result);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data.some((c: any) => c.name === "Uncategorized")).toBe(true);
|
||||
});
|
||||
|
||||
test("create_category creates a new category", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerCategoryTools(db);
|
||||
const result = await tools.create_category({
|
||||
name: "Shelter",
|
||||
icon: "tent",
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Shelter");
|
||||
expect(data.icon).toBe("tent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Thread Tools", () => {
|
||||
test("create_thread starts a thread with status active", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerThreadTools(db);
|
||||
const result = await tools.create_thread({
|
||||
name: "Handlebar Bag",
|
||||
categoryId: 1,
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Handlebar Bag");
|
||||
expect(data.status).toBe("active");
|
||||
});
|
||||
|
||||
test("add_candidate adds to thread", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerThreadTools(db);
|
||||
const thread = parseResult(
|
||||
await tools.create_thread({ name: "Saddle Bag", categoryId: 1 }),
|
||||
);
|
||||
const result = await tools.add_candidate({
|
||||
threadId: thread.id,
|
||||
name: "Apidura Racing",
|
||||
categoryId: 1,
|
||||
priceCents: 8500,
|
||||
pros: "Lightweight",
|
||||
cons: "Expensive",
|
||||
});
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Apidura Racing");
|
||||
expect(data.threadId).toBe(thread.id);
|
||||
expect(data.pros).toBe("Lightweight");
|
||||
});
|
||||
|
||||
test("resolve_thread picks winner and creates item", async () => {
|
||||
const db = createTestDb();
|
||||
const threadTools = registerThreadTools(db);
|
||||
const itemTools = registerItemTools(db);
|
||||
|
||||
// Create thread with two candidates
|
||||
const thread = parseResult(
|
||||
await threadTools.create_thread({ name: "Frame Bag", categoryId: 1 }),
|
||||
);
|
||||
const candidate1 = parseResult(
|
||||
await threadTools.add_candidate({
|
||||
threadId: thread.id,
|
||||
name: "Revelate Tangle",
|
||||
categoryId: 1,
|
||||
priceCents: 12000,
|
||||
}),
|
||||
);
|
||||
await threadTools.add_candidate({
|
||||
threadId: thread.id,
|
||||
name: "Ortlieb Frame Pack",
|
||||
categoryId: 1,
|
||||
priceCents: 9000,
|
||||
});
|
||||
|
||||
// Resolve with first candidate
|
||||
const resolveResult = await threadTools.resolve_thread({
|
||||
threadId: thread.id,
|
||||
candidateId: candidate1.id,
|
||||
});
|
||||
const resolveData = parseResult(resolveResult);
|
||||
expect(resolveData.success).toBe(true);
|
||||
expect(resolveData.item.name).toBe("Revelate Tangle");
|
||||
|
||||
// Check item was added to collection
|
||||
const items = parseResult(await itemTools.list_items({}));
|
||||
expect(items.some((i: any) => i.name === "Revelate Tangle")).toBe(true);
|
||||
|
||||
// Check thread is now resolved
|
||||
const threadList = parseResult(
|
||||
await threadTools.list_threads({ includeResolved: true }),
|
||||
);
|
||||
const resolved = threadList.find((t: any) => t.id === thread.id);
|
||||
expect(resolved.status).toBe("resolved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Setup Tools", () => {
|
||||
test("create_setup and list_setups", async () => {
|
||||
const db = createTestDb();
|
||||
const tools = registerSetupTools(db);
|
||||
await tools.create_setup({ name: "Weekend Trip" });
|
||||
const result = await tools.list_setups();
|
||||
const data = parseResult(result);
|
||||
expect(data.length).toBe(1);
|
||||
expect(data[0].name).toBe("Weekend Trip");
|
||||
});
|
||||
|
||||
test("get_setup returns setup with items", async () => {
|
||||
const db = createTestDb();
|
||||
const setupTools = registerSetupTools(db);
|
||||
const itemTools = registerItemTools(db);
|
||||
|
||||
const setup = parseResult(
|
||||
await setupTools.create_setup({ name: "Overnighter" }),
|
||||
);
|
||||
const item = parseResult(
|
||||
await itemTools.create_item({ name: "Bivvy", categoryId: 1 }),
|
||||
);
|
||||
await setupTools.update_setup({ id: setup.id, itemIds: [item.id] });
|
||||
|
||||
const result = await setupTools.get_setup({ id: setup.id });
|
||||
const data = parseResult(result);
|
||||
expect(data.name).toBe("Overnighter");
|
||||
expect(data.items.length).toBe(1);
|
||||
expect(data.items[0].name).toBe("Bivvy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP Collection Summary Resource", () => {
|
||||
test("returns overview with correct counts", () => {
|
||||
const db = createTestDb();
|
||||
|
||||
const summary = getCollectionSummary(db);
|
||||
expect(summary.overview).toBeDefined();
|
||||
expect(summary.overview.totalItems).toBe(0);
|
||||
expect(summary.overview.categoryCount).toBe(1); // Uncategorized
|
||||
expect(summary.itemsByCategory).toBeDefined();
|
||||
expect(summary.activeThreads).toBeDefined();
|
||||
expect(Array.isArray(summary.activeThreads)).toBe(true);
|
||||
});
|
||||
|
||||
test("reflects items and threads after creation", async () => {
|
||||
const db = createTestDb();
|
||||
const itemTools = registerItemTools(db);
|
||||
const threadTools = registerThreadTools(db);
|
||||
|
||||
await itemTools.create_item({
|
||||
name: "Tent",
|
||||
categoryId: 1,
|
||||
weightGrams: 1500,
|
||||
});
|
||||
await itemTools.create_item({
|
||||
name: "Sleeping Pad",
|
||||
categoryId: 1,
|
||||
weightGrams: 500,
|
||||
});
|
||||
await threadTools.create_thread({
|
||||
name: "Cook System",
|
||||
categoryId: 1,
|
||||
});
|
||||
|
||||
const summary = getCollectionSummary(db);
|
||||
expect(summary.overview.totalItems).toBe(2);
|
||||
expect(summary.overview.totalWeightGrams).toBe(2000);
|
||||
expect(summary.overview.activeThreadCount).toBe(1);
|
||||
expect(summary.itemsByCategory.Uncategorized).toBe(2);
|
||||
expect(summary.activeThreads.length).toBe(1);
|
||||
expect(summary.activeThreads[0].name).toBe("Cook System");
|
||||
});
|
||||
});
|
||||
86
tests/middleware/auth.test.ts
Normal file
86
tests/middleware/auth.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { requireAuth } from "../../src/server/middleware/auth";
|
||||
import {
|
||||
createApiKey,
|
||||
createSession,
|
||||
createUser,
|
||||
} from "../../src/server/services/auth.service";
|
||||
import { createTestDb } from "../helpers/db";
|
||||
|
||||
let db: ReturnType<typeof createTestDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono<{ Variables: { db?: any } }>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("db", db);
|
||||
await next();
|
||||
});
|
||||
|
||||
// Public GET
|
||||
app.get("/items", (c) => c.json({ ok: true }));
|
||||
|
||||
// Protected POST
|
||||
app.post("/items", requireAuth, (c) => c.json({ ok: true }));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("auth middleware", () => {
|
||||
test("allows GET requests without auth (middleware not applied to GET)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/items");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 403 setup_required when no users exist", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/items", { method: "POST" });
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("setup_required");
|
||||
});
|
||||
|
||||
test("rejects POST without auth when users exist", async () => {
|
||||
const app = createApp();
|
||||
await createUser(db, "admin", "pass");
|
||||
const res = await app.request("/items", { method: "POST" });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("allows POST with valid session cookie", async () => {
|
||||
const app = createApp();
|
||||
const user = await createUser(db, "admin", "pass");
|
||||
const session = createSession(db, user.id);
|
||||
const res = await app.request("/items", {
|
||||
method: "POST",
|
||||
headers: { Cookie: `gearbox_session=${session.id}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("allows POST with valid API key", async () => {
|
||||
const app = createApp();
|
||||
await createUser(db, "admin", "pass");
|
||||
const key = await createApiKey(db, "test");
|
||||
const res = await app.request("/items", {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": key.rawKey },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("rejects POST with invalid API key", async () => {
|
||||
const app = createApp();
|
||||
await createUser(db, "admin", "pass");
|
||||
const res = await app.request("/items", {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": "invalid" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
85
tests/middleware/rateLimit.test.ts
Normal file
85
tests/middleware/rateLimit.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
_resetForTesting,
|
||||
rateLimit,
|
||||
} from "../../src/server/middleware/rateLimit";
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono();
|
||||
app.post("/login", rateLimit, (c) => c.json({ ok: true }));
|
||||
app.post("/setup", rateLimit, (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
|
||||
return app.request(path, {
|
||||
method: "POST",
|
||||
headers: { "x-forwarded-for": ip },
|
||||
});
|
||||
}
|
||||
|
||||
describe("rateLimit middleware", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
it("allows first request through", async () => {
|
||||
const app = createApp();
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows up to 5 requests", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 429 after 5 requests", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login");
|
||||
}
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(429);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Too many attempts. Try again later.");
|
||||
});
|
||||
|
||||
it("includes Retry-After header on 429", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login");
|
||||
}
|
||||
const res = await makeRequest(app, "/login");
|
||||
expect(res.status).toBe(429);
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
expect(retryAfter).toBeTruthy();
|
||||
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tracks different IPs independently", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login", "10.0.0.1");
|
||||
}
|
||||
const blocked = await makeRequest(app, "/login", "10.0.0.1");
|
||||
expect(blocked.status).toBe(429);
|
||||
const allowed = await makeRequest(app, "/login", "10.0.0.2");
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("tracks different paths independently", async () => {
|
||||
const app = createApp();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await makeRequest(app, "/login");
|
||||
}
|
||||
const blockedLogin = await makeRequest(app, "/login");
|
||||
expect(blockedLogin.status).toBe(429);
|
||||
const allowedSetup = await makeRequest(app, "/setup");
|
||||
expect(allowedSetup.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user