feat(01-03): add slide-out panel, item form, category picker, and collection page

- CategoryPicker combobox with search, select, and inline create
- SlideOutPanel with backdrop, escape key, and slide animation
- ItemForm with all fields, validation, dollar-to-cents conversion
- Root layout with TotalsBar, panel, confirm dialog, and floating add button
- Collection page with card grid grouped by category, empty state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 22:46:24 +01:00
parent b099a47eb4
commit 12fd14ff41
5 changed files with 736 additions and 2 deletions

View File

@@ -1,13 +1,138 @@
import { createFileRoute } from "@tanstack/react-router";
import { useItems } from "../hooks/useItems";
import { useTotals } from "../hooks/useTotals";
import { CategoryHeader } from "../components/CategoryHeader";
import { ItemCard } from "../components/ItemCard";
import { useUIStore } from "../stores/uiStore";
export const Route = createFileRoute("/")({
component: CollectionPage,
});
function CollectionPage() {
const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals();
const openAddPanel = useUIStore((s) => s.openAddPanel);
if (itemsLoading) {
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 (!items || items.length === 0) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🎒</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Your collection is empty
</h2>
<p className="text-sm text-gray-500 mb-6">
Start cataloging your gear by adding your first item. Track weight,
price, and organize by category.
</p>
<button
type="button"
onClick={openAddPanel}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 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 your first item
</button>
</div>
</div>
);
}
// Group items by categoryId
const groupedItems = new Map<
number,
{ items: typeof items; categoryName: string; categoryEmoji: string }
>();
for (const item of 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,
});
}
}
// Build category totals lookup
const categoryTotalsMap = new Map<
number,
{ totalWeight: number; totalCost: number; itemCount: number }
>();
if (totals?.categories) {
for (const ct of totals.categories) {
categoryTotalsMap.set(ct.categoryId, {
totalWeight: ct.totalWeight,
totalCost: ct.totalCost,
itemCount: ct.itemCount,
});
}
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold text-gray-900">GearBox Collection</h1>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{Array.from(groupedItems.entries()).map(
([categoryId, { items: categoryItems, categoryName, categoryEmoji }]) => {
const catTotals = categoryTotalsMap.get(categoryId);
return (
<div key={categoryId} className="mb-8">
<CategoryHeader
categoryId={categoryId}
name={categoryName}
emoji={categoryEmoji}
totalWeight={catTotals?.totalWeight ?? 0}
totalCost={catTotals?.totalCost ?? 0}
itemCount={catTotals?.itemCount ?? categoryItems.length}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={categoryName}
categoryEmoji={categoryEmoji}
imageFilename={item.imageFilename}
/>
))}
</div>
</div>
);
},
)}
</div>
);
}