From 67099551d0b25840c4a785c8405e3ab1a5db6271 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 12:50:54 +0100 Subject: [PATCH] feat(03-02): setup list page, detail page, and item picker - Create SetupCard component with name, item count, weight, cost display - Build setups list page with inline create form and grid layout - Build setup detail page with category-grouped items and sticky totals bar - Create ItemPicker component in SlideOutPanel with category-grouped checkboxes - Add onRemove prop to ItemCard for non-destructive setup item removal - Setup detail has delete confirmation dialog with collection safety note - Empty states for both setup list and setup detail pages Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/ItemCard.tsx | 26 ++- src/client/components/ItemPicker.tsx | 141 ++++++++++++++ src/client/components/SetupCard.tsx | 43 +++++ src/client/routes/setups/$setupId.tsx | 267 +++++++++++++++++++++++++- src/client/routes/setups/index.tsx | 83 +++++++- 5 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 src/client/components/ItemPicker.tsx create mode 100644 src/client/components/SetupCard.tsx diff --git a/src/client/components/ItemCard.tsx b/src/client/components/ItemCard.tsx index 6fe4d2b..3535fac 100644 --- a/src/client/components/ItemCard.tsx +++ b/src/client/components/ItemCard.tsx @@ -9,6 +9,7 @@ interface ItemCardProps { categoryName: string; categoryEmoji: string; imageFilename: string | null; + onRemove?: () => void; } export function ItemCard({ @@ -19,6 +20,7 @@ export function ItemCard({ categoryName, categoryEmoji, imageFilename, + onRemove, }: ItemCardProps) { const openEditPanel = useUIStore((s) => s.openEditPanel); @@ -26,8 +28,30 @@ export function ItemCard({ + + + + + ); +} diff --git a/src/client/components/SetupCard.tsx b/src/client/components/SetupCard.tsx new file mode 100644 index 0000000..834f254 --- /dev/null +++ b/src/client/components/SetupCard.tsx @@ -0,0 +1,43 @@ +import { Link } from "@tanstack/react-router"; +import { formatWeight, formatPrice } from "../lib/formatters"; + +interface SetupCardProps { + id: number; + name: string; + itemCount: number; + totalWeight: number; + totalCost: number; +} + +export function SetupCard({ + id, + name, + itemCount, + totalWeight, + totalCost, +}: SetupCardProps) { + return ( + +
+

+ {name} +

+ + {itemCount} {itemCount === 1 ? "item" : "items"} + +
+
+ + {formatWeight(totalWeight)} + + + {formatPrice(totalCost)} + +
+ + ); +} diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index 7f8463c..9bad901 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -1,9 +1,268 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { + useSetup, + useDeleteSetup, + useRemoveSetupItem, +} from "../../hooks/useSetups"; +import { CategoryHeader } from "../../components/CategoryHeader"; +import { ItemCard } from "../../components/ItemCard"; +import { ItemPicker } from "../../components/ItemPicker"; +import { formatWeight, formatPrice } from "../../lib/formatters"; export const Route = createFileRoute("/setups/$setupId")({ - component: SetupDetailPlaceholder, + component: SetupDetailPage, }); -function SetupDetailPlaceholder() { - return
Setup detail loading...
; +function SetupDetailPage() { + const { setupId } = Route.useParams(); + const navigate = useNavigate(); + const numericId = Number(setupId); + const { data: setup, isLoading } = useSetup(numericId); + const deleteSetup = useDeleteSetup(); + const removeItem = useRemoveSetupItem(numericId); + + const [pickerOpen, setPickerOpen] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + if (isLoading) { + return ( +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ); + } + + if (!setup) { + return ( +
+

Setup not found.

+
+ ); + } + + // Compute totals from items + const totalWeight = setup.items.reduce( + (sum, item) => sum + (item.weightGrams ?? 0), + 0, + ); + const totalCost = setup.items.reduce( + (sum, item) => sum + (item.priceCents ?? 0), + 0, + ); + const itemCount = setup.items.length; + const currentItemIds = setup.items.map((item) => item.id); + + // Group items by category + const groupedItems = new Map< + number, + { + items: typeof setup.items; + categoryName: string; + categoryEmoji: string; + } + >(); + + for (const item of setup.items) { + const group = groupedItems.get(item.categoryId); + if (group) { + group.items.push(item); + } else { + groupedItems.set(item.categoryId, { + items: [item], + categoryName: item.categoryName, + categoryEmoji: item.categoryEmoji, + }); + } + } + + function handleDelete() { + deleteSetup.mutate(numericId, { + onSuccess: () => navigate({ to: "/setups" }), + }); + } + + return ( +
+ {/* Setup-specific sticky bar */} +
+
+

+ {setup.name} +

+
+ + {itemCount}{" "} + {itemCount === 1 ? "item" : "items"} + + + + {formatWeight(totalWeight)} + {" "} + total + + + + {formatPrice(totalCost)} + {" "} + cost + +
+
+
+ + {/* Actions */} +
+ + +
+ + {/* Empty state */} + {itemCount === 0 && ( +
+
+
๐Ÿ“ฆ
+

+ No items in this setup +

+

+ Add items from your collection to build this loadout. +

+ +
+
+ )} + + {/* Items grouped by category */} + {itemCount > 0 && ( +
+ {Array.from(groupedItems.entries()).map( + ([ + categoryId, + { items: categoryItems, categoryName, categoryEmoji }, + ]) => { + const catWeight = categoryItems.reduce( + (sum, item) => sum + (item.weightGrams ?? 0), + 0, + ); + const catCost = categoryItems.reduce( + (sum, item) => sum + (item.priceCents ?? 0), + 0, + ); + return ( +
+ +
+ {categoryItems.map((item) => ( + removeItem.mutate(item.id)} + /> + ))} +
+
+ ); + }, + )} +
+ )} + + {/* Item Picker */} + setPickerOpen(false)} + /> + + {/* Delete Confirmation Dialog */} + {confirmDelete && ( +
+
setConfirmDelete(false)} + /> +
+

+ Delete Setup +

+

+ Are you sure you want to delete{" "} + {setup.name}? This will not + remove items from your collection. +

+
+ + +
+
+
+ )} +
+ ); } diff --git a/src/client/routes/setups/index.tsx b/src/client/routes/setups/index.tsx index 189d849..2042b74 100644 --- a/src/client/routes/setups/index.tsx +++ b/src/client/routes/setups/index.tsx @@ -1,9 +1,86 @@ +import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { useSetups, useCreateSetup } from "../../hooks/useSetups"; +import { SetupCard } from "../../components/SetupCard"; export const Route = createFileRoute("/setups/")({ - component: SetupsPlaceholder, + component: SetupsListPage, }); -function SetupsPlaceholder() { - return
Setups loading...
; +function SetupsListPage() { + const [newSetupName, setNewSetupName] = useState(""); + const { data: setups, isLoading } = useSetups(); + const createSetup = useCreateSetup(); + + function handleCreateSetup(e: React.FormEvent) { + e.preventDefault(); + const name = newSetupName.trim(); + if (!name) return; + createSetup.mutate( + { name }, + { onSuccess: () => setNewSetupName("") }, + ); + } + + return ( +
+ {/* Create setup form */} +
+ setNewSetupName(e.target.value)} + placeholder="New setup name..." + className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+ + {/* Loading skeleton */} + {isLoading && ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ )} + + {/* Empty state */} + {!isLoading && (!setups || setups.length === 0) && ( +
+
+
๐Ÿ•๏ธ
+

+ No setups yet +

+

+ Create one to plan your loadout. +

+
+
+ )} + + {/* Setup grid */} + {!isLoading && setups && setups.length > 0 && ( +
+ {setups.map((setup) => ( + + ))} +
+ )} +
+ ); }