Merge branch 'worktree-agent-a4608610' into Develop
# Conflicts: # .planning/ROADMAP.md # .planning/STATE.md # src/client/routes/threads/$threadId.tsx
This commit is contained in:
@@ -63,7 +63,7 @@ Requirements for this milestone. Each maps to roadmap phases.
|
|||||||
- [ ] **DETAIL-01**: Clicking a collection item navigates to a full detail page (`/items/:id`) showing all item data
|
- [ ] **DETAIL-01**: Clicking a collection item navigates to a full detail page (`/items/:id`) showing all item data
|
||||||
- [ ] **DETAIL-02**: Clicking a catalog search result navigates to a public detail page (`/global-items/:id`) with "Add to Collection" button
|
- [ ] **DETAIL-02**: Clicking a catalog search result navigates to a public detail page (`/global-items/:id`) with "Add to Collection" button
|
||||||
- [ ] **DETAIL-03**: Item detail page has edit mode toggle for modifying personal fields (notes, category, quantity, purchase price)
|
- [ ] **DETAIL-03**: Item detail page has edit mode toggle for modifying personal fields (notes, category, quantity, purchase price)
|
||||||
- [ ] **DETAIL-04**: Thread candidates navigate to detail pages instead of opening slide-out panels
|
- [x] **DETAIL-04**: Thread candidates navigate to detail pages instead of opening slide-out panels
|
||||||
- [ ] **DETAIL-05**: Slide-out panels for items and candidates are removed from the application
|
- [ ] **DETAIL-05**: Slide-out panels for items and candidates are removed from the application
|
||||||
|
|
||||||
### Tags
|
### Tags
|
||||||
@@ -184,7 +184,7 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
| DETAIL-01 | Phase 21 | Pending |
|
| DETAIL-01 | Phase 21 | Pending |
|
||||||
| DETAIL-02 | Phase 21 | Pending |
|
| DETAIL-02 | Phase 21 | Pending |
|
||||||
| DETAIL-03 | Phase 21 | Pending |
|
| DETAIL-03 | Phase 21 | Pending |
|
||||||
| DETAIL-04 | Phase 21 | Pending |
|
| DETAIL-04 | Phase 21 | Complete |
|
||||||
| DETAIL-05 | Phase 21 | Pending |
|
| DETAIL-05 | Phase 21 | Pending |
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
- [x] **Phase 18: Global Items & Public Profiles** — Global item catalog, user profiles, and public setup sharing (completed 2026-04-05)
|
- [x] **Phase 18: Global Items & Public Profiles** — Global item catalog, user profiles, and public setup sharing (completed 2026-04-05)
|
||||||
- [x] **Phase 19: Reference Item Model & Tags Schema** — Collection items as references to global catalog, tag system for discovery (completed 2026-04-05)
|
- [x] **Phase 19: Reference Item Model & Tags Schema** — Collection items as references to global catalog, tag system for discovery (completed 2026-04-05)
|
||||||
- [x] **Phase 20: FAB & Full-Screen Catalog Search** — Global FAB with mini menu, full-screen catalog search with tag filtering (completed 2026-04-06)
|
- [x] **Phase 20: FAB & Full-Screen Catalog Search** — Global FAB with mini menu, full-screen catalog search with tag filtering (completed 2026-04-06)
|
||||||
- [ ] **Phase 21: Item & Catalog Detail Pages** — Full detail pages for collection items and catalog entries, replacing slide-out panels
|
- [x] **Phase 21: Item & Catalog Detail Pages** — Full detail pages for collection items and catalog entries, replacing slide-out panels (completed 2026-04-06)
|
||||||
- [ ] **Phase 22: Add-from-Catalog & Thread Integration** — Add catalog items to collection and threads, resolution creates reference items
|
- [ ] **Phase 22: Add-from-Catalog & Thread Integration** — Add catalog items to collection and threads, resolution creates reference items
|
||||||
- [ ] **Phase 23: Manual Entry Fallback** — Manual add for items not in catalog, non-functional submission prompt
|
- [ ] **Phase 23: Manual Entry Fallback** — Manual add for items not in catalog, non-functional submission prompt
|
||||||
|
|
||||||
@@ -266,6 +266,6 @@ Plans:
|
|||||||
| 18. Global Items & Public Profiles | v2.0 | 4/5 | Complete | 2026-04-05 |
|
| 18. Global Items & Public Profiles | v2.0 | 4/5 | Complete | 2026-04-05 |
|
||||||
| 19. Reference Item Model & Tags Schema | v2.0 | 3/3 | Complete | 2026-04-05 |
|
| 19. Reference Item Model & Tags Schema | v2.0 | 3/3 | Complete | 2026-04-05 |
|
||||||
| 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | Complete | 2026-04-06 |
|
| 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | Complete | 2026-04-06 |
|
||||||
| 21. Item & Catalog Detail Pages | v2.0 | 1/3 | In Progress| |
|
| 21. Item & Catalog Detail Pages | v2.0 | 1/1 | Complete | 2026-04-06 |
|
||||||
| 22. Add-from-Catalog & Thread Integration | v2.0 | 0/? | Not started | - |
|
| 22. Add-from-Catalog & Thread Integration | v2.0 | 0/? | Not started | - |
|
||||||
| 23. Manual Entry Fallback | v2.0 | 0/? | Not started | - |
|
| 23. Manual Entry Fallback | v2.0 | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||||||
milestone: v1.3
|
milestone: v1.3
|
||||||
milestone_name: Research & Decision Tools
|
milestone_name: Research & Decision Tools
|
||||||
status: planning
|
status: planning
|
||||||
stopped_at: Completed 21-01-PLAN.md
|
stopped_at: Completed 21-02-PLAN.md
|
||||||
last_updated: "2026-04-06T06:17:39.050Z"
|
last_updated: "2026-04-06T13:03:13.009Z"
|
||||||
last_activity: 2026-04-06
|
last_activity: 2026-04-06
|
||||||
progress:
|
progress:
|
||||||
total_phases: 14
|
total_phases: 15
|
||||||
completed_phases: 13
|
completed_phases: 14
|
||||||
total_plans: 38
|
total_plans: 39
|
||||||
completed_plans: 36
|
completed_plans: 37
|
||||||
percent: 0
|
percent: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@ See: .planning/PROJECT.md (updated 2026-04-03)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 21 of 21 (Item & Catalog Detail Pages)
|
Phase: 20 of 18 (PostgreSQL Migration)
|
||||||
Plan: 1 of 3 complete
|
Plan: Not started
|
||||||
Status: Executing
|
Status: Ready to plan
|
||||||
Last activity: 2026-04-06
|
Last activity: 2026-04-06
|
||||||
|
|
||||||
Progress: [----------] 0% (v2.0 milestone)
|
Progress: [----------] 0% (v2.0 milestone)
|
||||||
@@ -58,8 +58,8 @@ Key decisions made during v2.0 planning:
|
|||||||
- [Phase 20]: Created tags table in schema (was missing, needed for GET /api/tags endpoint)
|
- [Phase 20]: Created tags table in schema (was missing, needed for GET /api/tags endpoint)
|
||||||
- [Phase 20]: FAB visible on all authenticated routes, not just collection gear tab
|
- [Phase 20]: FAB visible on all authenticated routes, not just collection gear tab
|
||||||
- [Phase 20]: Add button on catalog search cards is a stub (Phase 21 wires actual flow)
|
- [Phase 20]: Add button on catalog search cards is a stub (Phase 21 wires actual flow)
|
||||||
- [Phase 21]: Edit mode on detail pages uses local useState, not UIStore panel state
|
- [Phase 21]: Candidate data fetched from useThread hook (find in array) not new API endpoint
|
||||||
- [Phase 21]: Add to Collection button on catalog detail page is a stub (Phase 22 wires actual flow)
|
- [Phase 21]: AddCandidateModal inline in thread page, local modal pattern replacing UIStore panel
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ None active.
|
|||||||
| # | Description | Date | Commit | Directory |
|
| # | Description | Date | Commit | Directory |
|
||||||
|---|-------------|------|--------|-----------|
|
|---|-------------|------|--------|-----------|
|
||||||
| 260406-j44 | Comprehensive dev seed script for bikepacking gear data | 2026-04-06 | — | [260406-j44-comprehensive-dev-seed-script-for-bikepa](./quick/260406-j44-comprehensive-dev-seed-script-for-bikepa/) |
|
| 260406-j44 | Comprehensive dev seed script for bikepacking gear data | 2026-04-06 | — | [260406-j44-comprehensive-dev-seed-script-for-bikepa](./quick/260406-j44-comprehensive-dev-seed-script-for-bikepa/) |
|
||||||
|
| Phase 21 P02 | 4min | 2 tasks | 2 files |
|
||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
@@ -78,6 +79,6 @@ None active.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-06T13:02:00.000Z
|
Last session: 2026-04-06T13:03:13.007Z
|
||||||
Stopped at: Completed 21-01-PLAN.md
|
Stopped at: Completed 21-02-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
phase: 21-item-catalog-detail-pages
|
||||||
|
plan: 02
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, tanstack-router, candidate-detail, modal-dialog, edit-mode]
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: 20-fab-full-screen-catalog-search
|
||||||
|
provides: FAB and catalog search overlay foundation
|
||||||
|
provides:
|
||||||
|
- Candidate detail page at /threads/:threadId/candidates/:candidateId with edit mode
|
||||||
|
- Restructured thread route directory for nested candidate routes
|
||||||
|
- Add-candidate modal dialog on thread page replacing slide-out panel
|
||||||
|
affects: [21-03-candidate-card-navigation-rewire]
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns: [nested-route-directory-structure, local-modal-pattern]
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- src/client/routes/threads/$threadId/candidates/$candidateId.tsx
|
||||||
|
modified:
|
||||||
|
- src/client/routes/threads/$threadId/index.tsx
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "StatusBadge on detail page is read-only (no onStatusChange) since status cycling happens on cards"
|
||||||
|
- "AddCandidateModal defined inline in thread index.tsx rather than separate component file"
|
||||||
|
- "Candidate data fetched from useThread hook (find in candidates array) rather than new endpoint"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Nested route directory: $threadId/index.tsx + $threadId/candidates/$candidateId.tsx"
|
||||||
|
- "Local modal pattern: useState in parent page controls modal visibility, no UIStore needed"
|
||||||
|
|
||||||
|
requirements-completed: [DETAIL-04]
|
||||||
|
|
||||||
|
duration: 4min
|
||||||
|
completed: 2026-04-06
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 21 Plan 02: Candidate Detail Page & Thread Route Restructuring Summary
|
||||||
|
|
||||||
|
**Candidate detail page with edit mode toggle at /threads/:threadId/candidates/:candidateId, thread route directory restructured for nested routes, add-candidate modal replacing slide-out panel**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 4 min
|
||||||
|
- **Started:** 2026-04-06T12:57:42Z
|
||||||
|
- **Completed:** 2026-04-06T13:02:26Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 2
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Restructured thread route from flat file to directory structure supporting nested candidate routes
|
||||||
|
- Created full candidate detail page with read/edit modes, image display, pros/cons, notes, and thread actions
|
||||||
|
- Replaced UIStore openCandidateAddPanel call with local modal dialog containing all form fields
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Restructure thread route and create candidate detail page** - `cecaf78` (feat)
|
||||||
|
2. **Task 2: Add candidate modal dialog on thread page** - `47b416e` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/client/routes/threads/$threadId/index.tsx` - Moved from $threadId.tsx, updated imports, added AddCandidateModal
|
||||||
|
- `src/client/routes/threads/$threadId/candidates/$candidateId.tsx` - New candidate detail page with edit mode toggle
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- StatusBadge rendered as read-only on detail page (status changes happen via card interactions)
|
||||||
|
- AddCandidateModal defined inline in the thread page file for simplicity
|
||||||
|
- Candidate data sourced from useThread hook (find in array) to avoid new API endpoint
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Candidate detail page is ready for Plan 03 to rewire CandidateCard clicks as navigation links
|
||||||
|
- Thread route directory structure supports the nested /candidates/:candidateId path
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 21-item-catalog-detail-pages*
|
||||||
|
*Completed: 2026-04-06*
|
||||||
@@ -19,6 +19,8 @@ import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
|||||||
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
||||||
import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId'
|
import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId'
|
||||||
import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId'
|
import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId'
|
||||||
|
import { Route as ThreadsThreadIdIndexRouteImport } from './routes/threads/$threadId/index'
|
||||||
|
import { Route as ThreadsThreadIdCandidatesCandidateIdRouteImport } from './routes/threads/$threadId/candidates/$candidateId'
|
||||||
|
|
||||||
const SettingsRoute = SettingsRouteImport.update({
|
const SettingsRoute = SettingsRouteImport.update({
|
||||||
id: '/settings',
|
id: '/settings',
|
||||||
@@ -70,6 +72,17 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
|
|||||||
path: '/global-items/$globalItemId',
|
path: '/global-items/$globalItemId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => ThreadsThreadIdRoute,
|
||||||
|
} as any)
|
||||||
|
const ThreadsThreadIdCandidatesCandidateIdRoute =
|
||||||
|
ThreadsThreadIdCandidatesCandidateIdRouteImport.update({
|
||||||
|
id: '/candidates/$candidateId',
|
||||||
|
path: '/candidates/$candidateId',
|
||||||
|
getParentRoute: () => ThreadsThreadIdRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -78,10 +91,12 @@ export interface FileRoutesByFullPath {
|
|||||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||||
'/items/$itemId': typeof ItemsItemIdRoute
|
'/items/$itemId': typeof ItemsItemIdRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRouteWithChildren
|
||||||
'/users/$userId': typeof UsersUserIdRoute
|
'/users/$userId': typeof UsersUserIdRoute
|
||||||
'/collection/': typeof CollectionIndexRoute
|
'/collection/': typeof CollectionIndexRoute
|
||||||
'/global-items/': typeof GlobalItemsIndexRoute
|
'/global-items/': typeof GlobalItemsIndexRoute
|
||||||
|
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
|
||||||
|
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -90,10 +105,11 @@ export interface FileRoutesByTo {
|
|||||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||||
'/items/$itemId': typeof ItemsItemIdRoute
|
'/items/$itemId': typeof ItemsItemIdRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
|
||||||
'/users/$userId': typeof UsersUserIdRoute
|
'/users/$userId': typeof UsersUserIdRoute
|
||||||
'/collection': typeof CollectionIndexRoute
|
'/collection': typeof CollectionIndexRoute
|
||||||
'/global-items': typeof GlobalItemsIndexRoute
|
'/global-items': typeof GlobalItemsIndexRoute
|
||||||
|
'/threads/$threadId': typeof ThreadsThreadIdIndexRoute
|
||||||
|
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -103,10 +119,12 @@ export interface FileRoutesById {
|
|||||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||||
'/items/$itemId': typeof ItemsItemIdRoute
|
'/items/$itemId': typeof ItemsItemIdRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRouteWithChildren
|
||||||
'/users/$userId': typeof UsersUserIdRoute
|
'/users/$userId': typeof UsersUserIdRoute
|
||||||
'/collection/': typeof CollectionIndexRoute
|
'/collection/': typeof CollectionIndexRoute
|
||||||
'/global-items/': typeof GlobalItemsIndexRoute
|
'/global-items/': typeof GlobalItemsIndexRoute
|
||||||
|
'/threads/$threadId/': typeof ThreadsThreadIdIndexRoute
|
||||||
|
'/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -121,6 +139,8 @@ export interface FileRouteTypes {
|
|||||||
| '/users/$userId'
|
| '/users/$userId'
|
||||||
| '/collection/'
|
| '/collection/'
|
||||||
| '/global-items/'
|
| '/global-items/'
|
||||||
|
| '/threads/$threadId/'
|
||||||
|
| '/threads/$threadId/candidates/$candidateId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -129,10 +149,11 @@ export interface FileRouteTypes {
|
|||||||
| '/global-items/$globalItemId'
|
| '/global-items/$globalItemId'
|
||||||
| '/items/$itemId'
|
| '/items/$itemId'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
|
||||||
| '/users/$userId'
|
| '/users/$userId'
|
||||||
| '/collection'
|
| '/collection'
|
||||||
| '/global-items'
|
| '/global-items'
|
||||||
|
| '/threads/$threadId'
|
||||||
|
| '/threads/$threadId/candidates/$candidateId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -145,6 +166,8 @@ export interface FileRouteTypes {
|
|||||||
| '/users/$userId'
|
| '/users/$userId'
|
||||||
| '/collection/'
|
| '/collection/'
|
||||||
| '/global-items/'
|
| '/global-items/'
|
||||||
|
| '/threads/$threadId/'
|
||||||
|
| '/threads/$threadId/candidates/$candidateId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -154,7 +177,7 @@ export interface RootRouteChildren {
|
|||||||
GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute
|
GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute
|
||||||
ItemsItemIdRoute: typeof ItemsItemIdRoute
|
ItemsItemIdRoute: typeof ItemsItemIdRoute
|
||||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
ThreadsThreadIdRoute: typeof ThreadsThreadIdRouteWithChildren
|
||||||
UsersUserIdRoute: typeof UsersUserIdRoute
|
UsersUserIdRoute: typeof UsersUserIdRoute
|
||||||
CollectionIndexRoute: typeof CollectionIndexRoute
|
CollectionIndexRoute: typeof CollectionIndexRoute
|
||||||
GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute
|
GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute
|
||||||
@@ -232,9 +255,38 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport
|
preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/threads/$threadId/': {
|
||||||
|
id: '/threads/$threadId/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/threads/$threadId/'
|
||||||
|
preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport
|
||||||
|
parentRoute: typeof ThreadsThreadIdRoute
|
||||||
|
}
|
||||||
|
'/threads/$threadId/candidates/$candidateId': {
|
||||||
|
id: '/threads/$threadId/candidates/$candidateId'
|
||||||
|
path: '/candidates/$candidateId'
|
||||||
|
fullPath: '/threads/$threadId/candidates/$candidateId'
|
||||||
|
preLoaderRoute: typeof ThreadsThreadIdCandidatesCandidateIdRouteImport
|
||||||
|
parentRoute: typeof ThreadsThreadIdRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThreadsThreadIdRouteChildren {
|
||||||
|
ThreadsThreadIdIndexRoute: typeof ThreadsThreadIdIndexRoute
|
||||||
|
ThreadsThreadIdCandidatesCandidateIdRoute: typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThreadsThreadIdRouteChildren: ThreadsThreadIdRouteChildren = {
|
||||||
|
ThreadsThreadIdIndexRoute: ThreadsThreadIdIndexRoute,
|
||||||
|
ThreadsThreadIdCandidatesCandidateIdRoute:
|
||||||
|
ThreadsThreadIdCandidatesCandidateIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThreadsThreadIdRouteWithChildren = ThreadsThreadIdRoute._addFileChildren(
|
||||||
|
ThreadsThreadIdRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
@@ -242,7 +294,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute,
|
GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute,
|
||||||
ItemsItemIdRoute: ItemsItemIdRoute,
|
ItemsItemIdRoute: ItemsItemIdRoute,
|
||||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
ThreadsThreadIdRoute: ThreadsThreadIdRouteWithChildren,
|
||||||
UsersUserIdRoute: UsersUserIdRoute,
|
UsersUserIdRoute: UsersUserIdRoute,
|
||||||
CollectionIndexRoute: CollectionIndexRoute,
|
CollectionIndexRoute: CollectionIndexRoute,
|
||||||
GlobalItemsIndexRoute: GlobalItemsIndexRoute,
|
GlobalItemsIndexRoute: GlobalItemsIndexRoute,
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
||||||
import { Reorder } from "framer-motion";
|
|
||||||
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";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/threads/$threadId")({
|
|
||||||
component: ThreadDetailPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function ThreadDetailPage() {
|
|
||||||
const { threadId: threadIdParam } = Route.useParams();
|
|
||||||
const threadId = Number(threadIdParam);
|
|
||||||
const { data: thread, isLoading, isError } = useThread(threadId);
|
|
||||||
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<
|
|
||||||
NonNullable<typeof thread>["candidates"] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
// Clear tempItems when server data changes
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
|
||||||
useEffect(() => {
|
|
||||||
setTempItems(null);
|
|
||||||
}, [thread?.candidates]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !thread) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
Thread not found
|
|
||||||
</h2>
|
|
||||||
<Link
|
|
||||||
to="/collection"
|
|
||||||
search={{ tab: "planning" }}
|
|
||||||
className="text-sm text-gray-600 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Back to planning
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = thread.status === "active";
|
|
||||||
const winningCandidate = thread.resolvedCandidateId
|
|
||||||
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const displayItems = tempItems ?? thread.candidates;
|
|
||||||
|
|
||||||
function handleDragEnd() {
|
|
||||||
if (!tempItems) return;
|
|
||||||
reorderMutation.mutate({
|
|
||||||
orderedIds: tempItems.map((c) => c.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Link
|
|
||||||
to="/collection"
|
|
||||||
search={{ tab: "planning" }}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
|
||||||
>
|
|
||||||
← Back to planning
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
isActive
|
|
||||||
? "bg-gray-100 text-gray-600"
|
|
||||||
: "bg-gray-100 text-gray-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isActive ? "Active" : "Resolved"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resolution banner */}
|
|
||||||
{!isActive && winningCandidate && (
|
|
||||||
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
<span className="font-medium">{winningCandidate.name}</span> was
|
|
||||||
picked as the winner and added to your collection.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toolbar: Add candidate + view toggle */}
|
|
||||||
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
|
||||||
{isActive && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCandidateAddPanel}
|
|
||||||
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
|
|
||||||
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 Candidate
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{thread.candidates.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCandidateViewMode("list")}
|
|
||||||
className={`p-1.5 rounded-md transition-colors ${
|
|
||||||
candidateViewMode === "list"
|
|
||||||
? "bg-gray-200 text-gray-900"
|
|
||||||
: "text-gray-400 hover:text-gray-600"
|
|
||||||
}`}
|
|
||||||
title="List view"
|
|
||||||
>
|
|
||||||
<LucideIcon name="layout-list" size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCandidateViewMode("grid")}
|
|
||||||
className={`p-1.5 rounded-md transition-colors ${
|
|
||||||
candidateViewMode === "grid"
|
|
||||||
? "bg-gray-200 text-gray-900"
|
|
||||||
: "text-gray-400 hover:text-gray-600"
|
|
||||||
}`}
|
|
||||||
title="Grid view"
|
|
||||||
>
|
|
||||||
<LucideIcon name="layout-grid" size={16} />
|
|
||||||
</button>
|
|
||||||
{thread.candidates.length >= 2 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCandidateViewMode("compare")}
|
|
||||||
className={`p-1.5 rounded-md transition-colors ${
|
|
||||||
candidateViewMode === "compare"
|
|
||||||
? "bg-gray-200 text-gray-900"
|
|
||||||
: "text-gray-400 hover:text-gray-600"
|
|
||||||
}`}
|
|
||||||
title="Compare view"
|
|
||||||
>
|
|
||||||
<LucideIcon name="columns-3" size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<SetupImpactSelector threadStatus={thread.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Candidates */}
|
|
||||||
{thread.candidates.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<div className="mb-3">
|
|
||||||
<LucideIcon
|
|
||||||
name="tag"
|
|
||||||
size={48}
|
|
||||||
className="text-gray-400 mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
|
||||||
No candidates yet
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Add your first candidate to start comparing.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : candidateViewMode === "compare" ? (
|
|
||||||
<ComparisonTable
|
|
||||||
candidates={displayItems}
|
|
||||||
resolvedCandidateId={thread.resolvedCandidateId}
|
|
||||||
deltas={deltas}
|
|
||||||
/>
|
|
||||||
) : candidateViewMode === "list" ? (
|
|
||||||
isActive ? (
|
|
||||||
<Reorder.Group
|
|
||||||
axis="y"
|
|
||||||
values={displayItems}
|
|
||||||
onReorder={setTempItems}
|
|
||||||
className="flex flex-col"
|
|
||||||
>
|
|
||||||
{displayItems.map((candidate, index) => (
|
|
||||||
<CandidateListItem
|
|
||||||
key={candidate.id}
|
|
||||||
candidate={candidate}
|
|
||||||
rank={index + 1}
|
|
||||||
isActive={isActive}
|
|
||||||
onStatusChange={(newStatus) =>
|
|
||||||
updateCandidate.mutate({
|
|
||||||
candidateId: candidate.id,
|
|
||||||
status: newStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
delta={deltas[candidate.id]}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Reorder.Group>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{displayItems.map((candidate, index) => (
|
|
||||||
<CandidateListItem
|
|
||||||
key={candidate.id}
|
|
||||||
candidate={candidate}
|
|
||||||
rank={index + 1}
|
|
||||||
isActive={isActive}
|
|
||||||
onStatusChange={(newStatus) =>
|
|
||||||
updateCandidate.mutate({
|
|
||||||
candidateId: candidate.id,
|
|
||||||
status: newStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
delta={deltas[candidate.id]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{thread.candidates.map((candidate, index) => (
|
|
||||||
<CandidateCard
|
|
||||||
key={candidate.id}
|
|
||||||
id={candidate.id}
|
|
||||||
name={candidate.name}
|
|
||||||
weightGrams={candidate.weightGrams}
|
|
||||||
priceCents={candidate.priceCents}
|
|
||||||
categoryName={candidate.categoryName}
|
|
||||||
categoryIcon={candidate.categoryIcon}
|
|
||||||
imageFilename={candidate.imageFilename}
|
|
||||||
imageUrl={candidate.imageUrl}
|
|
||||||
productUrl={candidate.productUrl}
|
|
||||||
threadId={threadId}
|
|
||||||
isActive={isActive}
|
|
||||||
status={candidate.status}
|
|
||||||
onStatusChange={(newStatus) =>
|
|
||||||
updateCandidate.mutate({
|
|
||||||
candidateId: candidate.id,
|
|
||||||
status: newStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pros={candidate.pros}
|
|
||||||
cons={candidate.cons}
|
|
||||||
rank={index + 1}
|
|
||||||
delta={deltas[candidate.id]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
506
src/client/routes/threads/$threadId/candidates/$candidateId.tsx
Normal file
506
src/client/routes/threads/$threadId/candidates/$candidateId.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CategoryPicker } from "../../../../components/CategoryPicker";
|
||||||
|
import { ImageUpload } from "../../../../components/ImageUpload";
|
||||||
|
import { StatusBadge } from "../../../../components/StatusBadge";
|
||||||
|
import { useUpdateCandidate } from "../../../../hooks/useCandidates";
|
||||||
|
import { useFormatters } from "../../../../hooks/useFormatters";
|
||||||
|
import { useThread } from "../../../../hooks/useThreads";
|
||||||
|
import { LucideIcon } from "../../../../lib/iconData";
|
||||||
|
import { useUIStore } from "../../../../stores/uiStore";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/threads/$threadId/candidates/$candidateId",
|
||||||
|
)({
|
||||||
|
component: CandidateDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
weightGrams: string;
|
||||||
|
priceDollars: string;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string;
|
||||||
|
productUrl: string;
|
||||||
|
imageFilename: string | null;
|
||||||
|
pros: string;
|
||||||
|
cons: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CandidateDetailPage() {
|
||||||
|
const { threadId: threadIdParam, candidateId: candidateIdParam } =
|
||||||
|
Route.useParams();
|
||||||
|
const threadId = Number(threadIdParam);
|
||||||
|
const candidateId = Number(candidateIdParam);
|
||||||
|
const { data: thread, isLoading, isError } = useThread(threadId);
|
||||||
|
const updateCandidate = useUpdateCandidate(threadId);
|
||||||
|
const { weight, price } = useFormatters();
|
||||||
|
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
||||||
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
|
(s) => s.openConfirmDeleteCandidate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState<FormData>({
|
||||||
|
name: "",
|
||||||
|
weightGrams: "",
|
||||||
|
priceDollars: "",
|
||||||
|
categoryId: 1,
|
||||||
|
notes: "",
|
||||||
|
productUrl: "",
|
||||||
|
imageFilename: null,
|
||||||
|
pros: "",
|
||||||
|
cons: "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const candidate = thread?.candidates.find((c) => c.id === candidateId);
|
||||||
|
const isActive = thread?.status === "active";
|
||||||
|
|
||||||
|
function enterEditMode() {
|
||||||
|
if (!candidate) return;
|
||||||
|
setForm({
|
||||||
|
name: candidate.name,
|
||||||
|
weightGrams:
|
||||||
|
candidate.weightGrams != null ? String(candidate.weightGrams) : "",
|
||||||
|
priceDollars:
|
||||||
|
candidate.priceCents != null
|
||||||
|
? (candidate.priceCents / 100).toFixed(2)
|
||||||
|
: "",
|
||||||
|
categoryId: candidate.categoryId,
|
||||||
|
notes: candidate.notes ?? "",
|
||||||
|
productUrl: candidate.productUrl ?? "",
|
||||||
|
imageFilename: candidate.imageFilename ?? null,
|
||||||
|
pros: candidate.pros ?? "",
|
||||||
|
cons: candidate.cons ?? "",
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setIsEditing(false);
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
newErrors.name = "Name is required";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.weightGrams &&
|
||||||
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
||||||
|
) {
|
||||||
|
newErrors.weightGrams = "Must be a positive number";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.priceDollars &&
|
||||||
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
||||||
|
) {
|
||||||
|
newErrors.priceDollars = "Must be a positive number";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.productUrl &&
|
||||||
|
form.productUrl.trim() !== "" &&
|
||||||
|
!form.productUrl.match(/^https?:\/\//)
|
||||||
|
) {
|
||||||
|
newErrors.productUrl = "Must be a valid URL (https://...)";
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
updateCandidate.mutate(
|
||||||
|
{
|
||||||
|
candidateId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
|
||||||
|
priceCents: form.priceDollars
|
||||||
|
? Math.round(Number(form.priceDollars) * 100)
|
||||||
|
: undefined,
|
||||||
|
categoryId: form.categoryId,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
productUrl: form.productUrl.trim() || undefined,
|
||||||
|
imageFilename: form.imageFilename ?? undefined,
|
||||||
|
pros: form.pros.trim() || undefined,
|
||||||
|
cons: form.cons.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsEditing(false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-4 bg-gray-100 rounded w-24" />
|
||||||
|
<div className="aspect-[16/9] bg-gray-100 rounded-xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-6 bg-gray-100 rounded w-48" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-7 bg-gray-100 rounded-full w-20" />
|
||||||
|
<div className="h-7 bg-gray-100 rounded-full w-24" />
|
||||||
|
<div className="h-7 bg-gray-100 rounded-full w-28" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 bg-gray-100 rounded w-full" />
|
||||||
|
<div className="h-4 bg-gray-100 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error / not found
|
||||||
|
if (isError || !thread || !candidate) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<Link
|
||||||
|
to="/threads/$threadId"
|
||||||
|
params={{ threadId: String(threadId) }}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to thread
|
||||||
|
</Link>
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-sm text-gray-500">Candidate not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = candidate.imageFilename
|
||||||
|
? `/uploads/${candidate.imageFilename}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{/* Back navigation */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to="/threads/$threadId"
|
||||||
|
params={{ threadId: String(threadId) }}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to thread
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<ImageUpload
|
||||||
|
value={form.imageFilename}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
onChange={(filename) =>
|
||||||
|
setForm((f) => ({ ...f, imageFilename: filename }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : imageUrl ? (
|
||||||
|
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={candidate.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
className="w-full text-2xl font-bold text-gray-900 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-red-500">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{candidate.name}
|
||||||
|
</h1>
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={enterEditMode}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="pencil" size={14} />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-6">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500">
|
||||||
|
Weight (g)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={form.weightGrams}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-24 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
placeholder="g"
|
||||||
|
/>
|
||||||
|
{errors.weightGrams && (
|
||||||
|
<p className="text-xs text-red-500">{errors.weightGrams}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500">
|
||||||
|
Price ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.priceDollars}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-24 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
placeholder="$"
|
||||||
|
/>
|
||||||
|
{errors.priceDollars && (
|
||||||
|
<p className="text-xs text-red-500">{errors.priceDollars}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<CategoryPicker
|
||||||
|
value={form.categoryId}
|
||||||
|
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{candidate.weightGrams != null && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-50 text-blue-500">
|
||||||
|
{weight(candidate.weightGrams)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{candidate.priceCents != null && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-50 text-green-500">
|
||||||
|
{price(candidate.priceCents)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{candidate.categoryName && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium bg-gray-50 text-gray-600">
|
||||||
|
<LucideIcon
|
||||||
|
name={candidate.categoryIcon || "folder"}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
{candidate.categoryName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<StatusBadge status={candidate.status} onStatusChange={() => {}} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Link */}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Product Link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.productUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, productUrl: 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"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
{errors.productUrl && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : candidate.productUrl ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<a
|
||||||
|
href={candidate.productUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="external-link" size={14} />
|
||||||
|
View product page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Pros & Cons */}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Pros
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.pros}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, pros: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
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 resize-none"
|
||||||
|
placeholder="One pro per line..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Cons
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.cons}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, cons: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
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 resize-none"
|
||||||
|
placeholder="One con per line..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : candidate.pros || candidate.cons ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
{candidate.pros && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Pros</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{candidate.pros
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((pro) => (
|
||||||
|
<li
|
||||||
|
key={pro}
|
||||||
|
className="flex items-start gap-2 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<LucideIcon
|
||||||
|
name="plus"
|
||||||
|
size={14}
|
||||||
|
className="text-green-500 mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
{pro.trim()}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{candidate.cons && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Cons</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{candidate.cons
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((con) => (
|
||||||
|
<li
|
||||||
|
key={con}
|
||||||
|
className="flex items-start gap-2 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<LucideIcon
|
||||||
|
name="minus"
|
||||||
|
size={14}
|
||||||
|
className="text-red-500 mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
{con.trim()}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
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 resize-none"
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : candidate.notes ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Notes</h3>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{candidate.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Edit mode actions */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateCandidate.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"
|
||||||
|
>
|
||||||
|
{updateCandidate.isPending ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thread-specific actions */}
|
||||||
|
{!isEditing && isActive && (
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openResolveDialog(threadId, candidateId)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 bg-amber-50 hover:bg-amber-100 text-amber-700 text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="trophy" size={14} />
|
||||||
|
Pick as winner
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openConfirmDeleteCandidate(candidateId)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm text-red-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="trash-2" size={14} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
629
src/client/routes/threads/$threadId/index.tsx
Normal file
629
src/client/routes/threads/$threadId/index.tsx
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { Reorder } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { CandidateCard } from "../../../components/CandidateCard";
|
||||||
|
import { CandidateListItem } from "../../../components/CandidateListItem";
|
||||||
|
import { CategoryPicker } from "../../../components/CategoryPicker";
|
||||||
|
import { ComparisonTable } from "../../../components/ComparisonTable";
|
||||||
|
import { ImageUpload } from "../../../components/ImageUpload";
|
||||||
|
import { SetupImpactSelector } from "../../../components/SetupImpactSelector";
|
||||||
|
import {
|
||||||
|
useCreateCandidate,
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/threads/$threadId/")({
|
||||||
|
component: ThreadDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ThreadDetailPage() {
|
||||||
|
const { threadId: threadIdParam } = Route.useParams();
|
||||||
|
const threadId = Number(threadIdParam);
|
||||||
|
const { data: thread, isLoading, isError } = useThread(threadId);
|
||||||
|
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 [addCandidateOpen, setAddCandidateOpen] = useState(false);
|
||||||
|
|
||||||
|
const [tempItems, setTempItems] = useState<
|
||||||
|
NonNullable<typeof thread>["candidates"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Clear tempItems when server data changes
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger
|
||||||
|
useEffect(() => {
|
||||||
|
setTempItems(null);
|
||||||
|
}, [thread?.candidates]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !thread) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Thread not found
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={{ tab: "planning" }}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Back to planning
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = thread.status === "active";
|
||||||
|
const winningCandidate = thread.resolvedCandidateId
|
||||||
|
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const displayItems = tempItems ?? thread.candidates;
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
if (!tempItems) return;
|
||||||
|
reorderMutation.mutate({
|
||||||
|
orderedIds: tempItems.map((c) => c.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={{ tab: "planning" }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
||||||
|
>
|
||||||
|
← Back to planning
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
isActive
|
||||||
|
? "bg-gray-100 text-gray-600"
|
||||||
|
: "bg-gray-100 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? "Active" : "Resolved"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resolution banner */}
|
||||||
|
{!isActive && winningCandidate && (
|
||||||
|
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
<span className="font-medium">{winningCandidate.name}</span> was
|
||||||
|
picked as the winner and added to your collection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar: Add candidate + view toggle */}
|
||||||
|
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddCandidateOpen(true)}
|
||||||
|
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
|
||||||
|
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 Candidate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{thread.candidates.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCandidateViewMode("list")}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
|
candidateViewMode === "list"
|
||||||
|
? "bg-gray-200 text-gray-900"
|
||||||
|
: "text-gray-400 hover:text-gray-600"
|
||||||
|
}`}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<LucideIcon name="layout-list" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCandidateViewMode("grid")}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
|
candidateViewMode === "grid"
|
||||||
|
? "bg-gray-200 text-gray-900"
|
||||||
|
: "text-gray-400 hover:text-gray-600"
|
||||||
|
}`}
|
||||||
|
title="Grid view"
|
||||||
|
>
|
||||||
|
<LucideIcon name="layout-grid" size={16} />
|
||||||
|
</button>
|
||||||
|
{thread.candidates.length >= 2 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCandidateViewMode("compare")}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
|
candidateViewMode === "compare"
|
||||||
|
? "bg-gray-200 text-gray-900"
|
||||||
|
: "text-gray-400 hover:text-gray-600"
|
||||||
|
}`}
|
||||||
|
title="Compare view"
|
||||||
|
>
|
||||||
|
<LucideIcon name="columns-3" size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SetupImpactSelector threadStatus={thread.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Candidates */}
|
||||||
|
{thread.candidates.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<LucideIcon
|
||||||
|
name="tag"
|
||||||
|
size={48}
|
||||||
|
className="text-gray-400 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||||
|
No candidates yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Add your first candidate to start comparing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : candidateViewMode === "compare" ? (
|
||||||
|
<ComparisonTable
|
||||||
|
candidates={displayItems}
|
||||||
|
resolvedCandidateId={thread.resolvedCandidateId}
|
||||||
|
deltas={deltas}
|
||||||
|
/>
|
||||||
|
) : candidateViewMode === "list" ? (
|
||||||
|
isActive ? (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={displayItems}
|
||||||
|
onReorder={setTempItems}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
{displayItems.map((candidate, index) => (
|
||||||
|
<CandidateListItem
|
||||||
|
key={candidate.id}
|
||||||
|
candidate={candidate}
|
||||||
|
rank={index + 1}
|
||||||
|
isActive={isActive}
|
||||||
|
onStatusChange={(newStatus) =>
|
||||||
|
updateCandidate.mutate({
|
||||||
|
candidateId: candidate.id,
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Reorder.Group>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{displayItems.map((candidate, index) => (
|
||||||
|
<CandidateListItem
|
||||||
|
key={candidate.id}
|
||||||
|
candidate={candidate}
|
||||||
|
rank={index + 1}
|
||||||
|
isActive={isActive}
|
||||||
|
onStatusChange={(newStatus) =>
|
||||||
|
updateCandidate.mutate({
|
||||||
|
candidateId: candidate.id,
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{thread.candidates.map((candidate, index) => (
|
||||||
|
<CandidateCard
|
||||||
|
key={candidate.id}
|
||||||
|
id={candidate.id}
|
||||||
|
name={candidate.name}
|
||||||
|
weightGrams={candidate.weightGrams}
|
||||||
|
priceCents={candidate.priceCents}
|
||||||
|
categoryName={candidate.categoryName}
|
||||||
|
categoryIcon={candidate.categoryIcon}
|
||||||
|
imageFilename={candidate.imageFilename}
|
||||||
|
imageUrl={candidate.imageUrl}
|
||||||
|
productUrl={candidate.productUrl}
|
||||||
|
threadId={threadId}
|
||||||
|
isActive={isActive}
|
||||||
|
status={candidate.status}
|
||||||
|
onStatusChange={(newStatus) =>
|
||||||
|
updateCandidate.mutate({
|
||||||
|
candidateId: candidate.id,
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pros={candidate.pros}
|
||||||
|
cons={candidate.cons}
|
||||||
|
rank={index + 1}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addCandidateOpen && (
|
||||||
|
<AddCandidateModal
|
||||||
|
threadId={threadId}
|
||||||
|
onClose={() => setAddCandidateOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddCandidateModalProps {
|
||||||
|
threadId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalFormData {
|
||||||
|
name: string;
|
||||||
|
weightGrams: string;
|
||||||
|
priceDollars: string;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string;
|
||||||
|
productUrl: string;
|
||||||
|
imageFilename: string | null;
|
||||||
|
pros: string;
|
||||||
|
cons: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_MODAL_FORM: ModalFormData = {
|
||||||
|
name: "",
|
||||||
|
weightGrams: "",
|
||||||
|
priceDollars: "",
|
||||||
|
categoryId: 1,
|
||||||
|
notes: "",
|
||||||
|
productUrl: "",
|
||||||
|
imageFilename: null,
|
||||||
|
pros: "",
|
||||||
|
cons: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) {
|
||||||
|
const createCandidate = useCreateCandidate(threadId);
|
||||||
|
const [form, setForm] = useState<ModalFormData>(INITIAL_MODAL_FORM);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
newErrors.name = "Name is required";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.weightGrams &&
|
||||||
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
||||||
|
) {
|
||||||
|
newErrors.weightGrams = "Must be a positive number";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.priceDollars &&
|
||||||
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
||||||
|
) {
|
||||||
|
newErrors.priceDollars = "Must be a positive number";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.productUrl &&
|
||||||
|
form.productUrl.trim() !== "" &&
|
||||||
|
!form.productUrl.match(/^https?:\/\//)
|
||||||
|
) {
|
||||||
|
newErrors.productUrl = "Must be a valid URL (https://...)";
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
createCandidate.mutate(
|
||||||
|
{
|
||||||
|
name: form.name.trim(),
|
||||||
|
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
|
||||||
|
priceCents: form.priceDollars
|
||||||
|
? Math.round(Number(form.priceDollars) * 100)
|
||||||
|
: undefined,
|
||||||
|
categoryId: form.categoryId,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
productUrl: form.productUrl.trim() || undefined,
|
||||||
|
imageFilename: form.imageFilename ?? undefined,
|
||||||
|
pros: form.pros.trim() || undefined,
|
||||||
|
cons: form.cons.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setForm(INITIAL_MODAL_FORM);
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Add Candidate</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<LucideIcon name="x" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
|
||||||
|
{/* Image */}
|
||||||
|
<ImageUpload
|
||||||
|
value={form.imageFilename}
|
||||||
|
onChange={(filename) =>
|
||||||
|
setForm((f) => ({ ...f, imageFilename: filename }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="modal-candidate-name"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="modal-candidate-name"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: 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"
|
||||||
|
placeholder="e.g. Osprey Talon 22"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight & Price row */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="modal-candidate-weight"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Weight (g)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="modal-candidate-weight"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={form.weightGrams}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
weightGrams: 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"
|
||||||
|
placeholder="e.g. 680"
|
||||||
|
/>
|
||||||
|
{errors.weightGrams && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">
|
||||||
|
{errors.weightGrams}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="modal-candidate-price"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Price ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="modal-candidate-price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.priceDollars}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
priceDollars: 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"
|
||||||
|
placeholder="e.g. 129.99"
|
||||||
|
/>
|
||||||
|
{errors.priceDollars && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">
|
||||||
|
{errors.priceDollars}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<CategoryPicker
|
||||||
|
value={form.categoryId}
|
||||||
|
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="modal-candidate-notes"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="modal-candidate-notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, notes: e.target.value }))
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
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 resize-none"
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pros */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="modal-candidate-pros"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Pros
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="modal-candidate-pros"
|
||||||
|
value={form.pros}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, pros: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
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 resize-none"
|
||||||
|
placeholder="One pro per line..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cons */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="modal-candidate-cons"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Cons
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="modal-candidate-cons"
|
||||||
|
value={form.cons}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, cons: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
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 resize-none"
|
||||||
|
placeholder="One con per line..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Link */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="modal-candidate-url"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Product Link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="modal-candidate-url"
|
||||||
|
type="url"
|
||||||
|
value={form.productUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, productUrl: 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"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
{errors.productUrl && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createCandidate.isPending}
|
||||||
|
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{createCandidate.isPending ? "Adding..." : "Add Candidate"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="py-2.5 px-4 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user