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) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ interface ItemCardProps {
|
|||||||
categoryName: string;
|
categoryName: string;
|
||||||
categoryEmoji: string;
|
categoryEmoji: string;
|
||||||
imageFilename: string | null;
|
imageFilename: string | null;
|
||||||
|
onRemove?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemCard({
|
export function ItemCard({
|
||||||
@@ -19,6 +20,7 @@ export function ItemCard({
|
|||||||
categoryName,
|
categoryName,
|
||||||
categoryEmoji,
|
categoryEmoji,
|
||||||
imageFilename,
|
imageFilename,
|
||||||
|
onRemove,
|
||||||
}: ItemCardProps) {
|
}: ItemCardProps) {
|
||||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||||
|
|
||||||
@@ -26,8 +28,30 @@ export function ItemCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEditPanel(id)}
|
onClick={() => openEditPanel(id)}
|
||||||
className="w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden"
|
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
|
||||||
>
|
>
|
||||||
|
{onRemove && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
||||||
|
title="Remove from setup"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{imageFilename && (
|
{imageFilename && (
|
||||||
<div className="aspect-[4/3] bg-gray-50">
|
<div className="aspect-[4/3] bg-gray-50">
|
||||||
<img
|
<img
|
||||||
|
|||||||
141
src/client/components/ItemPicker.tsx
Normal file
141
src/client/components/ItemPicker.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { SlideOutPanel } from "./SlideOutPanel";
|
||||||
|
import { useItems } from "../hooks/useItems";
|
||||||
|
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||||
|
import { formatWeight, formatPrice } from "../lib/formatters";
|
||||||
|
|
||||||
|
interface ItemPickerProps {
|
||||||
|
setupId: number;
|
||||||
|
currentItemIds: number[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemPicker({
|
||||||
|
setupId,
|
||||||
|
currentItemIds,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: ItemPickerProps) {
|
||||||
|
const { data: items } = useItems();
|
||||||
|
const syncItems = useSyncSetupItems(setupId);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Reset selected IDs when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedIds(new Set(currentItemIds));
|
||||||
|
}
|
||||||
|
}, [isOpen, currentItemIds]);
|
||||||
|
|
||||||
|
function handleToggle(itemId: number) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(itemId)) {
|
||||||
|
next.delete(itemId);
|
||||||
|
} else {
|
||||||
|
next.add(itemId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDone() {
|
||||||
|
syncItems.mutate(Array.from(selectedIds), {
|
||||||
|
onSuccess: () => onClose(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items by category
|
||||||
|
const grouped = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
categoryName: string;
|
||||||
|
categoryEmoji: string;
|
||||||
|
items: NonNullable<typeof items>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
for (const item of items) {
|
||||||
|
const group = grouped.get(item.categoryId);
|
||||||
|
if (group) {
|
||||||
|
group.items.push(item);
|
||||||
|
} else {
|
||||||
|
grouped.set(item.categoryId, {
|
||||||
|
categoryName: item.categoryName,
|
||||||
|
categoryEmoji: item.categoryEmoji,
|
||||||
|
items: [item],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOutPanel isOpen={isOpen} onClose={onClose} title="Select Items">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||||||
|
{!items || items.length === 0 ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
No items in your collection yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Array.from(grouped.entries()).map(
|
||||||
|
([categoryId, { categoryName, categoryEmoji, items: catItems }]) => (
|
||||||
|
<div key={categoryId} className="mb-4">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||||
|
{categoryEmoji} {categoryName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{catItems.map((item) => (
|
||||||
|
<label
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(item.id)}
|
||||||
|
onChange={() => handleToggle(item.id)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-sm text-gray-900 truncate">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
|
{item.weightGrams != null && formatWeight(item.weightGrams)}
|
||||||
|
{item.weightGrams != null && item.priceCents != null && " · "}
|
||||||
|
{item.priceCents != null && formatPrice(item.priceCents)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-gray-100 -mx-6 px-6 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDone}
|
||||||
|
disabled={syncItems.isPending}
|
||||||
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{syncItems.isPending ? "Saving..." : "Done"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideOutPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/client/components/SetupCard.tsx
Normal file
43
src/client/components/SetupCard.tsx
Normal file
@@ -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 (
|
||||||
|
<Link
|
||||||
|
to="/setups/$setupId"
|
||||||
|
params={{ setupId: String(id) }}
|
||||||
|
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 truncate">
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 shrink-0">
|
||||||
|
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
|
||||||
|
{formatWeight(totalWeight)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
|
||||||
|
{formatPrice(totalCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")({
|
export const Route = createFileRoute("/setups/$setupId")({
|
||||||
component: SetupDetailPlaceholder,
|
component: SetupDetailPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function SetupDetailPlaceholder() {
|
function SetupDetailPage() {
|
||||||
return <div>Setup detail loading...</div>;
|
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 (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-8 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 (!setup) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||||
|
<p className="text-gray-500">Setup not found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Setup-specific sticky bar */}
|
||||||
|
<div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-12">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 truncate">
|
||||||
|
{setup.name}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-700">{itemCount}</span>{" "}
|
||||||
|
{itemCount === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{formatWeight(totalWeight)}
|
||||||
|
</span>{" "}
|
||||||
|
total
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{formatPrice(totalCost)}
|
||||||
|
</span>{" "}
|
||||||
|
cost
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 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 Items
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Delete Setup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{itemCount === 0 && (
|
||||||
|
<div className="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">
|
||||||
|
No items in this setup
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Add items from your collection to build this loadout.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Add Items
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Items grouped by category */}
|
||||||
|
{itemCount > 0 && (
|
||||||
|
<div className="pb-6">
|
||||||
|
{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 (
|
||||||
|
<div key={categoryId} className="mb-8">
|
||||||
|
<CategoryHeader
|
||||||
|
categoryId={categoryId}
|
||||||
|
name={categoryName}
|
||||||
|
emoji={categoryEmoji}
|
||||||
|
totalWeight={catWeight}
|
||||||
|
totalCost={catCost}
|
||||||
|
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}
|
||||||
|
onRemove={() => removeItem.mutate(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Item Picker */}
|
||||||
|
<ItemPicker
|
||||||
|
setupId={numericId}
|
||||||
|
currentItemIds={currentItemIds}
|
||||||
|
isOpen={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
{confirmDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/30"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Delete Setup
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-medium">{setup.name}</span>? This will not
|
||||||
|
remove items from your collection.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteSetup.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{deleteSetup.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,86 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useSetups, useCreateSetup } from "../../hooks/useSetups";
|
||||||
|
import { SetupCard } from "../../components/SetupCard";
|
||||||
|
|
||||||
export const Route = createFileRoute("/setups/")({
|
export const Route = createFileRoute("/setups/")({
|
||||||
component: SetupsPlaceholder,
|
component: SetupsListPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function SetupsPlaceholder() {
|
function SetupsListPage() {
|
||||||
return <div>Setups loading...</div>;
|
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 (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{/* Create setup form */}
|
||||||
|
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSetupName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newSetupName.trim() || createSetup.isPending}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{createSetup.isPending ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-24 bg-gray-200 rounded-xl animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && (!setups || setups.length === 0) && (
|
||||||
|
<div className="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">
|
||||||
|
No setups yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Create one to plan your loadout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Setup grid */}
|
||||||
|
{!isLoading && setups && setups.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<SetupCard
|
||||||
|
key={setup.id}
|
||||||
|
id={setup.id}
|
||||||
|
name={setup.name}
|
||||||
|
itemCount={setup.itemCount}
|
||||||
|
totalWeight={setup.totalWeight}
|
||||||
|
totalCost={setup.totalCost}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user