Compare commits
13 Commits
15f146ee89
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f6647f76 | |||
| 0a40d7627f | |||
| 3eccbb12fd | |||
| fb925a9dce | |||
| 70e7cd2f0f | |||
| 33f735af67 | |||
| f8a1a00e0a | |||
| 27c36b6b9a | |||
| 684cfd3789 | |||
| 52751ae9d4 | |||
| 3fc737c872 | |||
| b993a0a831 | |||
| a8696c2a85 |
@@ -2,9 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [Develop]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [Develop]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
|
|||||||
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
35
docs/superpowers/specs/2026-04-03-user-menu-design.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# User Menu Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### UserMenu (`src/client/components/UserMenu.tsx`)
|
||||||
|
|
||||||
|
New component rendered by `TotalsBar` when authenticated.
|
||||||
|
|
||||||
|
**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements.
|
||||||
|
|
||||||
|
**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon:
|
||||||
|
|
||||||
|
1. **Settings** — `Settings` (gear) icon + "Settings" label, `<Link to="/settings">`
|
||||||
|
2. **Divider** — thin horizontal line
|
||||||
|
3. **Sign out** — `LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Click icon toggles open/close
|
||||||
|
- Click outside closes (via `useEffect` with document click listener)
|
||||||
|
- Clicking a menu item closes the dropdown
|
||||||
|
- Dropdown anchored right so it doesn't overflow viewport
|
||||||
|
|
||||||
|
### TotalsBar Changes (`src/client/components/TotalsBar.tsx`)
|
||||||
|
|
||||||
|
- When `isAuthenticated`: render `<UserMenu />` in place of the current "Sign out" button
|
||||||
|
- When not authenticated: keep the existing "Sign in" link unchanged
|
||||||
|
- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu)
|
||||||
|
|
||||||
|
## No Backend Changes
|
||||||
|
|
||||||
|
The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon.
|
||||||
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1775215076284,
|
"when": 1775215076284,
|
||||||
"tag": "0007_icy_prodigy",
|
"tag": "0007_icy_prodigy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775232090363,
|
||||||
|
"tag": "0008_loving_colossus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Reorder, useDragControls } from "framer-motion";
|
import { Reorder } from "framer-motion";
|
||||||
|
import { useRef } from "react";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
@@ -31,6 +32,7 @@ interface CandidateListItemProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||||
delta?: CandidateDelta;
|
delta?: CandidateDelta;
|
||||||
|
onDragEnd?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
||||||
@@ -53,8 +55,9 @@ export function CandidateListItem({
|
|||||||
isActive,
|
isActive,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
delta,
|
delta,
|
||||||
|
onDragEnd,
|
||||||
}: CandidateListItemProps) {
|
}: CandidateListItemProps) {
|
||||||
const controls = useDragControls();
|
const isDragging = useRef(false);
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
const openConfirmDeleteCandidate = useUIStore(
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
@@ -64,20 +67,15 @@ export function CandidateListItem({
|
|||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
|
||||||
const sharedClassName =
|
const sharedClassName =
|
||||||
"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";
|
"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 = (
|
const innerContent = (
|
||||||
<>
|
<>
|
||||||
{/* Drag handle */}
|
{/* Drag handle indicator */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<button
|
<span className="text-gray-300 shrink-0">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<LucideIcon name="grip-vertical" size={16} />
|
<LucideIcon name="grip-vertical" size={16} />
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rank badge */}
|
{/* Rank badge */}
|
||||||
@@ -103,7 +101,10 @@ export function CandidateListItem({
|
|||||||
{/* Name + badges */}
|
{/* Name + badges */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCandidateEditPanel(candidate.id)}
|
onClick={() => {
|
||||||
|
if (isDragging.current) return;
|
||||||
|
openCandidateEditPanel(candidate.id);
|
||||||
|
}}
|
||||||
className="flex-1 min-w-0 text-left"
|
className="flex-1 min-w-0 text-left"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||||
@@ -215,8 +216,17 @@ export function CandidateListItem({
|
|||||||
return (
|
return (
|
||||||
<Reorder.Item
|
<Reorder.Item
|
||||||
value={candidate}
|
value={candidate}
|
||||||
dragControls={controls}
|
onDragStart={() => {
|
||||||
dragListener={false}
|
isDragging.current = true;
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
isDragging.current = false;
|
||||||
|
}, 0);
|
||||||
|
onDragEnd?.();
|
||||||
|
}}
|
||||||
|
whileDrag={{ cursor: "grabbing" }}
|
||||||
|
style={{ marginBottom: 8, cursor: "grab" }}
|
||||||
className={sharedClassName}
|
className={sharedClassName}
|
||||||
>
|
>
|
||||||
{innerContent}
|
{innerContent}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useAuth, useLogout } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import type { WeightUnit } from "../lib/formatters";
|
import type { WeightUnit } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { UserMenu } from "./UserMenu";
|
||||||
|
|
||||||
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
||||||
|
|
||||||
@@ -21,7 +22,6 @@ export function TotalsBar({
|
|||||||
}: TotalsBarProps) {
|
}: TotalsBarProps) {
|
||||||
const { data } = useTotals();
|
const { data } = useTotals();
|
||||||
const { data: auth } = useAuth();
|
const { data: auth } = useAuth();
|
||||||
const logout = useLogout();
|
|
||||||
const isAuthenticated = !!auth?.user;
|
const isAuthenticated = !!auth?.user;
|
||||||
const { weight, price, unit } = useFormatters();
|
const { weight, price, unit } = useFormatters();
|
||||||
const updateSetting = useUpdateSetting();
|
const updateSetting = useUpdateSetting();
|
||||||
@@ -104,15 +104,8 @@ export function TotalsBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<button
|
<UserMenu />
|
||||||
type="button"
|
|
||||||
onClick={() => logout.mutate()}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
@@ -125,6 +118,5 @@ export function TotalsBar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/client/components/UserMenu.tsx
Normal file
57
src/client/components/UserMenu.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLogout } from "../hooks/useAuth";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="circle-user" size={22} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="settings" size={16} className="text-gray-400" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<div className="border-t border-gray-100 my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
logout.mutate();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="log-out" size={16} className="text-gray-400" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { CreateItem } from "../../shared/types";
|
import type { CreateItem } from "../../shared/types";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut, apiUpload } from "../lib/api";
|
import {
|
||||||
|
ApiError,
|
||||||
|
apiDelete,
|
||||||
|
apiGet,
|
||||||
|
apiPost,
|
||||||
|
apiPut,
|
||||||
|
apiUpload,
|
||||||
|
} from "../lib/api";
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -45,6 +52,8 @@ export function useItem(id: number | null) {
|
|||||||
queryKey: ["items", id],
|
queryKey: ["items", id],
|
||||||
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
|
queryFn: () => apiGet<ItemWithCategory>(`/api/items/${id}`),
|
||||||
enabled: id != null,
|
enabled: id != null,
|
||||||
|
retry: (count, error) =>
|
||||||
|
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 {
|
interface SetupListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -50,6 +57,8 @@ export function useSetup(setupId: number | null) {
|
|||||||
queryKey: ["setups", setupId],
|
queryKey: ["setups", setupId],
|
||||||
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||||
enabled: setupId != null,
|
enabled: setupId != null,
|
||||||
|
retry: (count, error) =>
|
||||||
|
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 {
|
interface ThreadListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -61,6 +61,8 @@ export function useThread(threadId: number | null) {
|
|||||||
queryKey: ["threads", threadId],
|
queryKey: ["threads", threadId],
|
||||||
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
|
queryFn: () => apiGet<ThreadWithCandidates>(`/api/threads/${threadId}`),
|
||||||
enabled: threadId != null,
|
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(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public status: number,
|
public status: number,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -16,6 +16,11 @@ export const Route = createFileRoute("/collection/")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
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 = {
|
const slideVariants = {
|
||||||
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||||
@@ -33,6 +38,26 @@ function CollectionPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
<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}>
|
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tab}
|
key={tab}
|
||||||
|
|||||||
@@ -36,12 +36,11 @@ function ThreadDetailPage() {
|
|||||||
thread?.categoryId ?? 0,
|
thread?.categoryId ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [tempItems, setTempItems] =
|
const [tempItems, setTempItems] = useState<
|
||||||
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
NonNullable<typeof thread>["candidates"] | null
|
||||||
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
|
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTempItems(null);
|
setTempItems(null);
|
||||||
@@ -88,10 +87,9 @@ function ThreadDetailPage() {
|
|||||||
|
|
||||||
function handleDragEnd() {
|
function handleDragEnd() {
|
||||||
if (!tempItems) return;
|
if (!tempItems) return;
|
||||||
reorderMutation.mutate(
|
reorderMutation.mutate({
|
||||||
{ orderedIds: tempItems.map((c) => c.id) },
|
orderedIds: tempItems.map((c) => c.id),
|
||||||
{ onSettled: () => setTempItems(null) },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,7 +129,7 @@ function ThreadDetailPage() {
|
|||||||
|
|
||||||
{/* Toolbar: Add candidate + view toggle */}
|
{/* Toolbar: Add candidate + view toggle */}
|
||||||
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
||||||
{isActive && candidateViewMode !== "compare" && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCandidateAddPanel}
|
onClick={openCandidateAddPanel}
|
||||||
@@ -227,8 +225,7 @@ function ThreadDetailPage() {
|
|||||||
axis="y"
|
axis="y"
|
||||||
values={displayItems}
|
values={displayItems}
|
||||||
onReorder={setTempItems}
|
onReorder={setTempItems}
|
||||||
onPointerUp={handleDragEnd}
|
className="flex flex-col"
|
||||||
className="flex flex-col gap-2"
|
|
||||||
>
|
>
|
||||||
{displayItems.map((candidate, index) => (
|
{displayItems.map((candidate, index) => (
|
||||||
<CandidateListItem
|
<CandidateListItem
|
||||||
@@ -243,6 +240,7 @@ function ThreadDetailPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
delta={deltas[candidate.id]}
|
delta={deltas[candidate.id]}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Reorder.Group>
|
</Reorder.Group>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
@@ -24,24 +25,14 @@ export const categoryToolDefinitions = [
|
|||||||
{
|
{
|
||||||
name: "list_categories",
|
name: "list_categories",
|
||||||
description: "List all gear categories.",
|
description: "List all gear categories.",
|
||||||
inputSchema: {
|
inputSchema: {},
|
||||||
type: "object" as const,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_category",
|
name: "create_category",
|
||||||
description: "Create a new gear category.",
|
description: "Create a new gear category.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Category name"),
|
||||||
properties: {
|
icon: z.string().optional().describe("Icon name (defaults to 'package')"),
|
||||||
name: { type: "string", description: "Category name" },
|
|
||||||
icon: {
|
|
||||||
type: "string",
|
|
||||||
description: "Icon name (defaults to 'package')",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["name"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import { fetchImageFromUrl } from "../../services/image.service.ts";
|
import { fetchImageFromUrl } from "../../services/image.service.ts";
|
||||||
|
|
||||||
interface ToolResult {
|
interface ToolResult {
|
||||||
@@ -20,14 +21,9 @@ export const imageToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
|
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
url: z
|
||||||
properties: {
|
.string()
|
||||||
url: {
|
.describe("URL of the image to fetch (jpeg, png, or webp)"),
|
||||||
type: "string",
|
|
||||||
description: "URL of the image to fetch (jpeg, png, or webp)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["url"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
@@ -29,24 +30,14 @@ export const itemToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"List all items in the gear collection, optionally filtered by category.",
|
"List all items in the gear collection, optionally filtered by category.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
categoryId: z.number().optional().describe("Filter items by category ID"),
|
||||||
properties: {
|
|
||||||
categoryId: {
|
|
||||||
type: "number",
|
|
||||||
description: "Filter items by category ID",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_item",
|
name: "get_item",
|
||||||
description: "Get a single item by its ID, including all details.",
|
description: "Get a single item by its ID, including all details.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("The item ID"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "The item ID" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -54,60 +45,48 @@ export const itemToolDefinitions = [
|
|||||||
description:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Item name"),
|
||||||
properties: {
|
categoryId: z.number().describe("Category ID"),
|
||||||
name: { type: "string", description: "Item name" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
notes: z.string().optional().describe("Notes about the item"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
productUrl: z.string().optional().describe("URL to the product page"),
|
||||||
notes: { type: "string", description: "Notes about the item" },
|
imageFilename: z
|
||||||
productUrl: { type: "string", description: "URL to the product page" },
|
.string()
|
||||||
imageFilename: {
|
.optional()
|
||||||
type: "string",
|
.describe("Filename of an uploaded image"),
|
||||||
description: "Filename of an uploaded image",
|
imageSourceUrl: z
|
||||||
},
|
.string()
|
||||||
imageSourceUrl: {
|
.optional()
|
||||||
type: "string",
|
.describe("Original URL the image was fetched from"),
|
||||||
description: "Original URL the image was fetched from",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["name", "categoryId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_item",
|
name: "update_item",
|
||||||
description: "Update an existing item's fields.",
|
description: "Update an existing item's fields.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("The item ID to update"),
|
||||||
properties: {
|
name: z.string().optional().describe("Item name"),
|
||||||
id: { type: "number", description: "The item ID to update" },
|
categoryId: z.number().optional().describe("Category ID"),
|
||||||
name: { type: "string", description: "Item name" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
notes: z.string().optional().describe("Notes about the item"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
productUrl: z.string().optional().describe("URL to the product page"),
|
||||||
notes: { type: "string", description: "Notes about the item" },
|
imageFilename: z
|
||||||
productUrl: { type: "string", description: "URL to the product page" },
|
.string()
|
||||||
imageFilename: {
|
.optional()
|
||||||
type: "string",
|
.describe("Filename of an uploaded image"),
|
||||||
description: "Filename of an uploaded image",
|
imageSourceUrl: z
|
||||||
},
|
.string()
|
||||||
imageSourceUrl: {
|
.optional()
|
||||||
type: "string",
|
.describe("Original URL the image was fetched from"),
|
||||||
description: "Original URL the image was fetched from",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "delete_item",
|
name: "delete_item",
|
||||||
description: "Delete an item from the gear collection by ID.",
|
description: "Delete an item from the gear collection by ID.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("The item ID to delete"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "The item ID to delete" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createSetup,
|
createSetup,
|
||||||
@@ -28,31 +29,20 @@ export const setupToolDefinitions = [
|
|||||||
name: "list_setups",
|
name: "list_setups",
|
||||||
description:
|
description:
|
||||||
"List all gear setups with item counts and weight/cost totals.",
|
"List all gear setups with item counts and weight/cost totals.",
|
||||||
inputSchema: {
|
inputSchema: {},
|
||||||
type: "object" as const,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_setup",
|
name: "get_setup",
|
||||||
description: "Get a setup with all its items and details.",
|
description: "Get a setup with all its items and details.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Setup ID"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "Setup ID" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_setup",
|
name: "create_setup",
|
||||||
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
|
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Setup name"),
|
||||||
properties: {
|
|
||||||
name: { type: "string", description: "Setup name" },
|
|
||||||
},
|
|
||||||
required: ["name"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,17 +50,12 @@ export const setupToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
|
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Setup ID"),
|
||||||
properties: {
|
name: z.string().optional().describe("New setup name"),
|
||||||
id: { type: "number", description: "Setup ID" },
|
itemIds: z
|
||||||
name: { type: "string", description: "New setup name" },
|
.array(z.number())
|
||||||
itemIds: {
|
.optional()
|
||||||
type: "array",
|
.describe("Array of item IDs to include in the setup"),
|
||||||
items: { type: "number" },
|
|
||||||
description: "Array of item IDs to include in the setup",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import type { db as prodDb } from "../../../db/index.ts";
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
import {
|
import {
|
||||||
createCandidate,
|
createCandidate,
|
||||||
@@ -31,14 +32,12 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
includeResolved: z
|
||||||
properties: {
|
.boolean()
|
||||||
includeResolved: {
|
.optional()
|
||||||
type: "boolean",
|
.describe(
|
||||||
description:
|
|
||||||
"Include resolved threads (default: false, only active threads)",
|
"Include resolved threads (default: false, only active threads)",
|
||||||
},
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,11 +45,7 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Get a thread with all its candidates for detailed comparison.",
|
"Get a thread with all its candidates for detailed comparison.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Thread ID"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "Thread ID" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,15 +53,8 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
name: z.string().describe("Thread name (e.g. 'Handlebar bag')"),
|
||||||
properties: {
|
categoryId: z.number().describe("Category ID"),
|
||||||
name: {
|
|
||||||
type: "string",
|
|
||||||
description: "Thread name (e.g. 'Handlebar bag')",
|
|
||||||
},
|
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
|
||||||
},
|
|
||||||
required: ["name", "categoryId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,35 +62,24 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
threadId: z.number().describe("Thread ID"),
|
||||||
properties: {
|
candidateId: z.number().describe("ID of the winning candidate"),
|
||||||
threadId: { type: "number", description: "Thread ID" },
|
|
||||||
candidateId: {
|
|
||||||
type: "number",
|
|
||||||
description: "ID of the winning candidate",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["threadId", "candidateId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "add_candidate",
|
name: "add_candidate",
|
||||||
description: "Add a candidate option to a research thread for comparison.",
|
description: "Add a candidate option to a research thread for comparison.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
threadId: z.number().describe("Thread ID"),
|
||||||
properties: {
|
name: z.string().describe("Candidate name"),
|
||||||
threadId: { type: "number", description: "Thread ID" },
|
categoryId: z.number().describe("Category ID"),
|
||||||
name: { type: "string", description: "Candidate name" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
notes: z.string().optional().describe("Notes"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
productUrl: z.string().optional().describe("Product URL"),
|
||||||
notes: { type: "string", description: "Notes" },
|
imageFilename: z.string().optional().describe("Image filename"),
|
||||||
productUrl: { type: "string", description: "Product URL" },
|
pros: z.string().optional().describe("Pros of this candidate"),
|
||||||
imageFilename: { type: "string", description: "Image filename" },
|
cons: z.string().optional().describe("Cons of this candidate"),
|
||||||
pros: { type: "string", description: "Pros of this candidate" },
|
|
||||||
cons: { type: "string", description: "Cons of this candidate" },
|
|
||||||
},
|
|
||||||
required: ["threadId", "name", "categoryId"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,36 +87,28 @@ export const threadToolDefinitions = [
|
|||||||
description:
|
description:
|
||||||
"Update a candidate's details (name, price, pros, cons, etc.).",
|
"Update a candidate's details (name, price, pros, cons, etc.).",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Candidate ID"),
|
||||||
properties: {
|
name: z.string().optional().describe("Candidate name"),
|
||||||
id: { type: "number", description: "Candidate ID" },
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
||||||
name: { type: "string", description: "Candidate name" },
|
priceCents: z.number().optional().describe("Price in cents"),
|
||||||
weightGrams: { type: "number", description: "Weight in grams" },
|
categoryId: z.number().optional().describe("Category ID"),
|
||||||
priceCents: { type: "number", description: "Price in cents" },
|
notes: z.string().optional().describe("Notes"),
|
||||||
categoryId: { type: "number", description: "Category ID" },
|
productUrl: z.string().optional().describe("Product URL"),
|
||||||
notes: { type: "string", description: "Notes" },
|
imageFilename: z.string().optional().describe("Image filename"),
|
||||||
productUrl: { type: "string", description: "Product URL" },
|
imageSourceUrl: z.string().optional().describe("Image source URL"),
|
||||||
imageFilename: { type: "string", description: "Image filename" },
|
status: z
|
||||||
imageSourceUrl: { type: "string", description: "Image source URL" },
|
.string()
|
||||||
status: {
|
.optional()
|
||||||
type: "string",
|
.describe("Status: researching, ordered, or arrived"),
|
||||||
description: "Status: researching, ordered, or arrived",
|
pros: z.string().optional().describe("Pros"),
|
||||||
},
|
cons: z.string().optional().describe("Cons"),
|
||||||
pros: { type: "string", description: "Pros" },
|
|
||||||
cons: { type: "string", description: "Cons" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "remove_candidate",
|
name: "remove_candidate",
|
||||||
description: "Remove a candidate from a research thread.",
|
description: "Remove a candidate from a research thread.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
id: z.number().describe("Candidate ID to remove"),
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "Candidate ID to remove" },
|
|
||||||
},
|
|
||||||
required: ["id"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function getAllItems(db: Db = prodDb) {
|
|||||||
name: items.name,
|
name: items.name,
|
||||||
weightGrams: items.weightGrams,
|
weightGrams: items.weightGrams,
|
||||||
priceCents: items.priceCents,
|
priceCents: items.priceCents,
|
||||||
|
quantity: items.quantity,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
productUrl: items.productUrl,
|
productUrl: items.productUrl,
|
||||||
@@ -63,6 +64,7 @@ export function createItem(
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
weightGrams: data.weightGrams ?? null,
|
weightGrams: data.weightGrams ?? null,
|
||||||
priceCents: data.priceCents ?? null,
|
priceCents: data.priceCents ?? null,
|
||||||
|
quantity: data.quantity ?? 1,
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
productUrl: data.productUrl ?? null,
|
productUrl: data.productUrl ?? null,
|
||||||
@@ -80,6 +82,7 @@ export function updateItem(
|
|||||||
name: string;
|
name: string;
|
||||||
weightGrams: number;
|
weightGrams: number;
|
||||||
priceCents: number;
|
priceCents: number;
|
||||||
|
quantity: number;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
productUrl: string;
|
productUrl: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user