diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 6e1a287..e89e123 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -217,11 +217,7 @@ Plans: 3. Thread candidates navigate to detail pages instead of opening slide-out panels 4. Item slide-out panel and candidate slide-out panel are removed from the root layout 5. No visual distinction between reference items and standalone items — same layout, some fields may be empty -**Plans:** 3 plans -Plans: -- [ ] 21-01-PLAN.md — Item detail page with edit mode + catalog detail page enhancement -- [ ] 21-02-PLAN.md — Candidate detail page with edit mode + add-candidate modal -- [ ] 21-03-PLAN.md — Rewire card navigation + remove slide-out panels + UIStore cleanup +**Plans**: TBD **UI hint**: yes ### Phase 22: Add-from-Catalog & Thread Integration @@ -270,6 +266,6 @@ Plans: | 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 | | 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | Complete | 2026-04-06 | -| 21. Item & Catalog Detail Pages | v2.0 | 0/3 | Planning | - | +| 21. Item & Catalog Detail Pages | v2.0 | 1/3 | In Progress| | | 22. Add-from-Catalog & Thread Integration | v2.0 | 0/? | Not started | - | | 23. Manual Entry Fallback | v2.0 | 0/? | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 7fa2db6..8badf22 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: Research & Decision Tools -status: executing -stopped_at: Phase 21 context gathered -last_updated: "2026-04-06T12:56:33.435Z" -last_activity: 2026-04-06 -- Phase 21 execution started +status: planning +stopped_at: Completed 21-01-PLAN.md +last_updated: "2026-04-06T06:17:39.050Z" +last_activity: 2026-04-06 progress: - total_phases: 15 + total_phases: 14 completed_phases: 13 - total_plans: 41 + total_plans: 38 completed_plans: 36 percent: 0 --- @@ -21,14 +21,14 @@ progress: See: .planning/PROJECT.md (updated 2026-04-03) **Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing. -**Current focus:** Phase 21 — item-catalog-detail-pages +**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration) ## Current Position -Phase: 21 (item-catalog-detail-pages) — EXECUTING -Plan: 1 of 3 -Status: Executing Phase 21 -Last activity: 2026-04-06 -- Phase 21 execution started +Phase: 21 of 21 (Item & Catalog Detail Pages) +Plan: 1 of 3 complete +Status: Executing +Last activity: 2026-04-06 Progress: [----------] 0% (v2.0 milestone) @@ -58,6 +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]: 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 21]: Edit mode on detail pages uses local useState, not UIStore panel state +- [Phase 21]: Add to Collection button on catalog detail page is a stub (Phase 22 wires actual flow) ### Pending Todos @@ -76,6 +78,6 @@ None active. ## Session Continuity -Last session: 2026-04-06T12:42:30.311Z -Stopped at: Phase 21 context gathered -Resume file: .planning/phases/21-item-catalog-detail-pages/21-CONTEXT.md +Last session: 2026-04-06T13:02:00.000Z +Stopped at: Completed 21-01-PLAN.md +Resume file: None diff --git a/.planning/phases/21-item-catalog-detail-pages/21-01-SUMMARY.md b/.planning/phases/21-item-catalog-detail-pages/21-01-SUMMARY.md new file mode 100644 index 0000000..70777f5 --- /dev/null +++ b/.planning/phases/21-item-catalog-detail-pages/21-01-SUMMARY.md @@ -0,0 +1,96 @@ +--- +phase: 21-item-catalog-detail-pages +plan: 01 +subsystem: ui +tags: [react, tanstack-router, detail-page, edit-mode, collection] + +requires: + - phase: 20-fab-full-screen-catalog-search + provides: "Global item hooks, catalog search overlay, FAB menu" +provides: + - "Private item detail page at /items/:id with edit mode toggle" + - "Enhanced catalog detail page at /global-items/:id with Add to Collection stub" +affects: [21-02, 21-03, phase-22] + +tech-stack: + added: [] + patterns: + - "Detail page edit mode via local useState toggle" + - "Form state initialized from item data on edit enter" + +key-files: + created: + - src/client/routes/items/$itemId.tsx + modified: + - src/client/routes/global-items/$globalItemId.tsx + +key-decisions: + - "Used type assertion for imageUrl on item data (API enriches but TS interface lacks field)" + - "Edit mode uses local form state initialized on toggle, not controlled by UIStore" + +patterns-established: + - "Detail page pattern: hero image, name, spec badges, content sections, metadata footer" + - "Edit mode pattern: enterEditMode initializes form from data, Save calls mutation with onSuccess exit" + +requirements-completed: [DETAIL-01, DETAIL-02, DETAIL-03] + +duration: 4min +completed: 2026-04-06 +--- + +# Phase 21 Plan 01: Detail Pages Summary + +**Private item detail page with edit mode toggle at /items/:id, and enhanced catalog detail page with Add to Collection stub button** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-04-06T12:57:54Z +- **Completed:** 2026-04-06T13:02:13Z +- **Tasks:** 2 +- **Files modified:** 2 + +## Accomplishments +- Full item detail page at `/items/:id` with hero image (or category icon placeholder), name, weight/price/category badges, notes, product link, and metadata +- Edit mode toggle: read-only gallery view by default, inline editable fields with Save/Cancel when Edit clicked +- Catalog detail page enhanced with image placeholder when no image, and "Add to Collection" stub button + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create private item detail page with edit mode toggle** - `3228bca` (feat) +2. **Task 2: Enhance catalog detail page with Add to Collection button** - `408025b` (feat) + +## Files Created/Modified +- `src/client/routes/items/$itemId.tsx` - Private item detail page with edit mode, duplicate, delete, back nav +- `src/client/routes/global-items/$globalItemId.tsx` - Enhanced with image placeholder and Add to Collection stub button + +## Decisions Made +- Used type assertion to access `imageUrl` field that API enriches via `withImageUrl` but isn't in the TypeScript `ItemWithCategory` interface +- Edit mode managed via local `useState` rather than UIStore, keeping state scoped to the page + +## Deviations from Plan + +None - plan executed exactly as written. + +## Known Stubs + +| File | Line | Stub | Reason | +|------|------|------|--------| +| `src/client/routes/global-items/$globalItemId.tsx` | 133 | "Add to Collection" button logs to console | Actual add-from-catalog flow wired in Phase 22 (per D-10) | + +## Issues Encountered +None + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- Both detail pages exist and are ready for Plan 03 to rewire card click handlers to navigate here +- Plan 02 (candidate detail page) can proceed independently +- Phase 22 will wire the Add to Collection button to actual functionality + +--- +*Phase: 21-item-catalog-detail-pages* +*Completed: 2026-04-06* diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 274d5b7..09e0c65 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as CollectionIndexRouteImport } from './routes/collection/index' import { Route as UsersUserIdRouteImport } from './routes/users/$userId' import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId' import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId' +import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId' import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId' const SettingsRoute = SettingsRouteImport.update({ @@ -59,6 +60,11 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({ path: '/setups/$setupId', getParentRoute: () => rootRouteImport, } as any) +const ItemsItemIdRoute = ItemsItemIdRouteImport.update({ + id: '/items/$itemId', + path: '/items/$itemId', + getParentRoute: () => rootRouteImport, +} as any) const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({ id: '/global-items/$globalItemId', path: '/global-items/$globalItemId', @@ -70,6 +76,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/settings': typeof SettingsRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute + '/items/$itemId': typeof ItemsItemIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/users/$userId': typeof UsersUserIdRoute @@ -81,6 +88,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/settings': typeof SettingsRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute + '/items/$itemId': typeof ItemsItemIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/users/$userId': typeof UsersUserIdRoute @@ -93,6 +101,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/settings': typeof SettingsRoute '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute + '/items/$itemId': typeof ItemsItemIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/users/$userId': typeof UsersUserIdRoute @@ -106,6 +115,7 @@ export interface FileRouteTypes { | '/login' | '/settings' | '/global-items/$globalItemId' + | '/items/$itemId' | '/setups/$setupId' | '/threads/$threadId' | '/users/$userId' @@ -117,6 +127,7 @@ export interface FileRouteTypes { | '/login' | '/settings' | '/global-items/$globalItemId' + | '/items/$itemId' | '/setups/$setupId' | '/threads/$threadId' | '/users/$userId' @@ -128,6 +139,7 @@ export interface FileRouteTypes { | '/login' | '/settings' | '/global-items/$globalItemId' + | '/items/$itemId' | '/setups/$setupId' | '/threads/$threadId' | '/users/$userId' @@ -140,6 +152,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute SettingsRoute: typeof SettingsRoute GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute + ItemsItemIdRoute: typeof ItemsItemIdRoute SetupsSetupIdRoute: typeof SetupsSetupIdRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute UsersUserIdRoute: typeof UsersUserIdRoute @@ -205,6 +218,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SetupsSetupIdRouteImport parentRoute: typeof rootRouteImport } + '/items/$itemId': { + id: '/items/$itemId' + path: '/items/$itemId' + fullPath: '/items/$itemId' + preLoaderRoute: typeof ItemsItemIdRouteImport + parentRoute: typeof rootRouteImport + } '/global-items/$globalItemId': { id: '/global-items/$globalItemId' path: '/global-items/$globalItemId' @@ -220,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, SettingsRoute: SettingsRoute, GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute, + ItemsItemIdRoute: ItemsItemIdRoute, SetupsSetupIdRoute: SetupsSetupIdRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute, UsersUserIdRoute: UsersUserIdRoute, diff --git a/src/client/routes/global-items/$globalItemId.tsx b/src/client/routes/global-items/$globalItemId.tsx index a591e8b..c6a227a 100644 --- a/src/client/routes/global-items/$globalItemId.tsx +++ b/src/client/routes/global-items/$globalItemId.tsx @@ -55,15 +55,27 @@ function GlobalItemDetail() { {/* Image */} - {item.imageUrl && ( -
+
+ {item.imageUrl ? ( {`${item.brand} -
- )} + ) : ( +
+ + + +
+ )} +
{/* Header */}
@@ -114,6 +126,17 @@ function GlobalItemDetail() { )}
+ {/* Add to Collection */} +
+ +
+ {/* Description */} {item.description && (
diff --git a/src/client/routes/items/$itemId.tsx b/src/client/routes/items/$itemId.tsx new file mode 100644 index 0000000..f86e0a2 --- /dev/null +++ b/src/client/routes/items/$itemId.tsx @@ -0,0 +1,454 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { CategoryPicker } from "../../components/CategoryPicker"; +import { ImageUpload } from "../../components/ImageUpload"; +import { useFormatters } from "../../hooks/useFormatters"; +import { useDuplicateItem, useItem, useUpdateItem } from "../../hooks/useItems"; +import { LucideIcon } from "../../lib/iconData"; +import { useUIStore } from "../../stores/uiStore"; + +export const Route = createFileRoute("/items/$itemId")({ + component: ItemDetail, +}); + +interface EditFormState { + name: string; + weightGrams: string; + priceDollars: string; + quantity: number; + categoryId: number; + notes: string; + productUrl: string; + imageFilename: string | null; +} + +function ItemDetail() { + const { itemId } = Route.useParams(); + const navigate = useNavigate(); + const { + data: item, + isLoading, + error, + } = useItem(Number(itemId)) as ReturnType & { + data: + | (NonNullable["data"]> & { + imageUrl?: string | null; + }) + | undefined; + }; + const { weight, price } = useFormatters(); + const updateItem = useUpdateItem(); + const duplicateItem = useDuplicateItem(); + const openConfirmDelete = useUIStore((s) => s.openConfirmDelete); + const openExternalLink = useUIStore((s) => s.openExternalLink); + + const [isEditing, setIsEditing] = useState(false); + const [form, setForm] = useState({ + name: "", + weightGrams: "", + priceDollars: "", + quantity: 1, + categoryId: 0, + notes: "", + productUrl: "", + imageFilename: null, + }); + + function enterEditMode() { + if (!item) return; + setForm({ + name: item.name, + weightGrams: item.weightGrams != null ? String(item.weightGrams) : "", + priceDollars: + item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "", + quantity: item.quantity, + categoryId: item.categoryId, + notes: item.notes || "", + productUrl: item.productUrl || "", + imageFilename: item.imageFilename, + }); + setIsEditing(true); + } + + function cancelEdit() { + setIsEditing(false); + } + + function handleSave() { + if (!item) return; + const weightGrams = form.weightGrams.trim() + ? Math.round(Number(form.weightGrams)) + : null; + const priceCents = form.priceDollars.trim() + ? Math.round(Number(form.priceDollars) * 100) + : null; + + updateItem.mutate( + { + id: item.id, + name: form.name.trim(), + weightGrams, + priceCents, + quantity: form.quantity, + categoryId: form.categoryId, + notes: form.notes.trim() || null, + productUrl: form.productUrl.trim() || null, + imageFilename: form.imageFilename, + }, + { + onSuccess: () => setIsEditing(false), + }, + ); + } + + function handleDuplicate() { + if (!item) return; + duplicateItem.mutate(item.id, { + onSuccess: (newItem) => { + navigate({ + to: "/items/$itemId", + params: { itemId: String(newItem.id) }, + }); + }, + }); + } + + function handleDelete() { + if (!item) return; + openConfirmDelete(item.id); + } + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !item) { + return ( +
+ + ← Back to collection + +
+

Item not found

+
+
+ ); + } + + const imageUrl = item.imageUrl || null; + + return ( +
+ {/* Top bar */} +
+ + ← Back to collection + + {!isEditing && ( +
+ + + +
+ )} + {isEditing && ( +
+ + +
+ )} +
+ + {/* Hero image */} + {isEditing ? ( +
+ + setForm((f) => ({ ...f, imageFilename: filename })) + } + /> +
+ ) : ( +
+ {imageUrl ? ( + {item.name} + ) : ( +
+ +
+ )} +
+ )} + + {/* Header / Name */} +
+ {isEditing ? ( + setForm((f) => ({ ...f, name: e.target.value }))} + className="w-full text-2xl font-bold text-gray-900 border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + placeholder="Item name" + /> + ) : ( +

{item.name}

+ )} +
+ + {/* Badges / Specs */} + {isEditing ? ( +
+
+ + + setForm((f) => ({ + ...f, + weightGrams: e.target.value, + })) + } + className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + placeholder="0" + /> +
+
+ + + setForm((f) => ({ + ...f, + priceDollars: e.target.value, + })) + } + className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + placeholder="0.00" + /> +
+
+ + + setForm((f) => ({ + ...f, + quantity: Math.max(1, Number(e.target.value)), + })) + } + className="w-full py-2 px-3 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + /> +
+
+ + setForm((f) => ({ ...f, categoryId: id }))} + /> +
+
+ ) : ( +
+ {item.weightGrams != null && ( + + {weight(item.weightGrams)} + + )} + {item.priceCents != null && ( + + {price(item.priceCents)} + + )} + + + {item.categoryName} + + {item.quantity > 1 && ( + + Qty: {item.quantity} + + )} +
+ )} + + {/* Notes */} + {isEditing ? ( +
+ +