feat(03-02): navigation restructure, TotalsBar refactor, and setup hooks

- Move collection view from / to /collection with gear/planning tabs
- Rewrite / as dashboard with three summary cards (Collection, Planning, Setups)
- Refactor TotalsBar to accept optional stats/linkTo/title props
- Create DashboardCard component for dashboard summary cards
- Create useSetups hooks (CRUD + sync/remove item mutations)
- Update __root.tsx with route-aware TotalsBar, FAB visibility, resolve navigation
- Add item picker and setup delete UI state to uiStore
- Invalidate setups queries on item update/delete for stale data prevention
- Update routeTree.gen.ts with new collection/setups routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 12:49:03 +01:00
parent c2b8985d37
commit 86a7a0def1
11 changed files with 637 additions and 270 deletions

View File

@@ -0,0 +1,50 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
interface DashboardCardProps {
to: string;
search?: Record<string, string>;
title: string;
icon: ReactNode;
stats: Array<{ label: string; value: string }>;
emptyText?: string;
}
export function DashboardCard({
to,
search,
title,
icon,
stats,
emptyText,
}: DashboardCardProps) {
const allZero = stats.every(
(s) => s.value === "0" || s.value === "$0.00" || s.value === "0g",
);
return (
<Link
to={to}
search={search}
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-6"
>
<div className="flex items-center gap-3 mb-4">
<span className="text-2xl">{icon}</span>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div>
<div className="space-y-1.5">
{stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between">
<span className="text-sm text-gray-500">{stat.label}</span>
<span className="text-sm font-medium text-gray-700">
{stat.value}
</span>
</div>
))}
</div>
{allZero && emptyText && (
<p className="mt-4 text-sm text-blue-600 font-medium">{emptyText}</p>
)}
</Link>
);
}

View File

@@ -1,36 +1,57 @@
import { Link } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals"; import { useTotals } from "../hooks/useTotals";
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatWeight, formatPrice } from "../lib/formatters";
export function TotalsBar() { interface TotalsBarProps {
title?: string;
stats?: Array<{ label: string; value: string }>;
linkTo?: string;
}
export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps) {
const { data } = useTotals(); const { data } = useTotals();
const global = data?.global; // When no stats provided, use global totals (backward compatible)
const displayStats = stats ?? (data?.global
? [
{ label: "items", value: String(data.global.itemCount) },
{ label: "total", value: formatWeight(data.global.totalWeight) },
{ label: "spent", value: formatPrice(data.global.totalCost) },
]
: [
{ label: "items", value: "0" },
{ label: "total", value: formatWeight(null) },
{ label: "spent", value: formatPrice(null) },
]);
const titleElement = linkTo ? (
<Link to={linkTo} className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
{title}
</Link>
) : (
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
);
// If stats prop is explicitly an empty array, show title only (dashboard mode)
const showStats = stats === undefined || stats.length > 0;
return ( return (
<div className="sticky top-0 z-10 bg-white border-b border-gray-100"> <div className="sticky top-0 z-10 bg-white border-b border-gray-100">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14"> <div className="flex items-center justify-between h-14">
<h1 className="text-lg font-semibold text-gray-900">GearBox</h1> {titleElement}
{showStats && (
<div className="flex items-center gap-6 text-sm text-gray-500"> <div className="flex items-center gap-6 text-sm text-gray-500">
<span> {displayStats.map((stat) => (
<span key={stat.label}>
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">
{global?.itemCount ?? 0} {stat.value}
</span>{" "} </span>{" "}
items {stat.label}
</span>
<span>
<span className="font-medium text-gray-700">
{formatWeight(global?.totalWeight ?? null)}
</span>{" "}
total
</span>
<span>
<span className="font-medium text-gray-700">
{formatPrice(global?.totalCost ?? null)}
</span>{" "}
spent
</span> </span>
))}
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -52,6 +52,7 @@ export function useUpdateItem() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }); queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] }); queryClient.invalidateQueries({ queryKey: ["totals"] });
queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },
}); });
} }
@@ -64,6 +65,7 @@ export function useDeleteItem() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }); queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] }); queryClient.invalidateQueries({ queryKey: ["totals"] });
queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },
}); });
} }

View File

@@ -0,0 +1,107 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
interface SetupListItem {
id: number;
name: string;
createdAt: string;
updatedAt: string;
itemCount: number;
totalWeight: number;
totalCost: number;
}
interface SetupItemWithCategory {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryEmoji: string;
}
interface SetupWithItems {
id: number;
name: string;
createdAt: string;
updatedAt: string;
items: SetupItemWithCategory[];
}
export type { SetupListItem, SetupWithItems, SetupItemWithCategory };
export function useSetups() {
return useQuery({
queryKey: ["setups"],
queryFn: () => apiGet<SetupListItem[]>("/api/setups"),
});
}
export function useSetup(setupId: number | null) {
return useQuery({
queryKey: ["setups", setupId],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
enabled: setupId != null,
});
}
export function useCreateSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string }) =>
apiPost<SetupListItem>("/api/setups", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useUpdateSetup(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name?: string }) =>
apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useDeleteSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useSyncSetupItems(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemIds: number[]) =>
apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}
export function useRemoveSetupItem(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: number) =>
apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},
});
}

View File

@@ -10,33 +10,73 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
import { Route as SetupsIndexRouteImport } from './routes/setups/index'
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ThreadsThreadIdRoute = ThreadsThreadIdRouteImport.update({
id: '/threads/$threadId',
path: '/threads/$threadId',
getParentRoute: () => rootRouteImport,
} as any)
const CollectionIndexRoute = CollectionIndexRouteImport.update({
id: '/collection/',
path: '/collection/',
getParentRoute: () => rootRouteImport,
} as any)
const SetupsIndexRoute = SetupsIndexRouteImport.update({
id: '/setups/',
path: '/setups/',
getParentRoute: () => rootRouteImport,
} as any)
const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
id: '/setups/$setupId',
path: '/setups/$setupId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection': typeof CollectionIndexRoute
'/setups': typeof SetupsIndexRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection': typeof CollectionIndexRoute
'/setups': typeof SetupsIndexRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/collection/': typeof CollectionIndexRoute
'/setups/': typeof SetupsIndexRoute
'/setups/$setupId': typeof SetupsSetupIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' fullPaths: '/' | '/threads/$threadId' | '/collection' | '/setups' | '/setups/$setupId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' to: '/' | '/threads/$threadId' | '/collection' | '/setups' | '/setups/$setupId'
id: '__root__' | '/' id: '__root__' | '/' | '/threads/$threadId' | '/collection/' | '/setups/' | '/setups/$setupId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
CollectionIndexRoute: typeof CollectionIndexRoute
SetupsIndexRoute: typeof SetupsIndexRoute
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -48,11 +88,43 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/threads/$threadId': {
id: '/threads/$threadId'
path: '/threads/$threadId'
fullPath: '/threads/$threadId'
preLoaderRoute: typeof ThreadsThreadIdRouteImport
parentRoute: typeof rootRouteImport
}
'/collection/': {
id: '/collection/'
path: '/collection'
fullPath: '/collection'
preLoaderRoute: typeof CollectionIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/setups/': {
id: '/setups/'
path: '/setups'
fullPath: '/setups'
preLoaderRoute: typeof SetupsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/setups/$setupId': {
id: '/setups/$setupId'
path: '/setups/$setupId'
fullPath: '/setups/$setupId'
preLoaderRoute: typeof SetupsSetupIdRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
CollectionIndexRoute: CollectionIndexRoute,
SetupsIndexRoute: SetupsIndexRoute,
SetupsSetupIdRoute: SetupsSetupIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -59,14 +59,38 @@ function RootLayout() {
const isItemPanelOpen = panelMode !== "closed"; const isItemPanelOpen = panelMode !== "closed";
const isCandidatePanelOpen = candidatePanelMode !== "closed"; const isCandidatePanelOpen = candidatePanelMode !== "closed";
// Detect if we're on a thread detail page to get the threadId for candidate forms // Route matching for contextual behavior
const matchRoute = useMatchRoute(); const matchRoute = useMatchRoute();
const threadMatch = matchRoute({ const threadMatch = matchRoute({
to: "/threads/$threadId", to: "/threads/$threadId",
fuzzy: true, fuzzy: true,
}) as { threadId?: string } | false; }) as { threadId?: string } | false;
const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null; const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
const isDashboard = !!matchRoute({ to: "/" });
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
// Determine TotalsBar props based on current route
const totalsBarProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
: isSetupDetail
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
: { linkTo: "/" }; // All other pages: default stats + link to dashboard
// On dashboard, don't show the default global stats - pass empty stats
// On collection, let TotalsBar fetch its own global stats (default behavior)
const finalTotalsProps = isDashboard
? { stats: [] as Array<{ label: string; value: string }> }
: isCollection
? { linkTo: "/" }
: { linkTo: "/" };
// FAB visibility: only show on /collection route when gear tab is active
const collectionSearch = matchRoute({ to: "/collection" }) as { tab?: string } | false;
const showFab = isCollection && (!collectionSearch || (collectionSearch as Record<string, string>).tab !== "planning");
// Show a minimal loading state while checking onboarding status // Show a minimal loading state while checking onboarding status
if (onboardingLoading) { if (onboardingLoading) {
return ( return (
@@ -78,7 +102,7 @@ function RootLayout() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<TotalsBar /> <TotalsBar {...finalTotalsProps} />
<Outlet /> <Outlet />
{/* Item Slide-out Panel */} {/* Item Slide-out Panel */}
@@ -135,13 +159,13 @@ function RootLayout() {
onClose={closeResolveDialog} onClose={closeResolveDialog}
onResolved={() => { onResolved={() => {
closeResolveDialog(); closeResolveDialog();
navigate({ to: "/", search: { tab: "planning" } }); navigate({ to: "/collection", search: { tab: "planning" } });
}} }}
/> />
)} )}
{/* Floating Add Button - only on gear tab */} {/* Floating Add Button - only on collection gear tab */}
{!threadMatch && ( {showFab && (
<button <button
type="button" type="button"
onClick={openAddPanel} onClick={openAddPanel}

View File

@@ -0,0 +1,252 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { useItems } from "../../hooks/useItems";
import { useTotals } from "../../hooks/useTotals";
import { useThreads, useCreateThread } from "../../hooks/useThreads";
import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard";
import { ThreadTabs } from "../../components/ThreadTabs";
import { ThreadCard } from "../../components/ThreadCard";
import { useUIStore } from "../../stores/uiStore";
const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"),
});
export const Route = createFileRoute("/collection/")({
validateSearch: searchSchema,
component: CollectionPage,
});
function CollectionPage() {
const { tab } = Route.useSearch();
const navigate = useNavigate();
function handleTabChange(newTab: "gear" | "planning") {
navigate({ to: "/collection", search: { tab: newTab } });
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<ThreadTabs active={tab} onChange={handleTabChange} />
<div className="mt-6">
{tab === "gear" ? <CollectionView /> : <PlanningView />}
</div>
</div>
);
}
function CollectionView() {
const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals();
const openAddPanel = useUIStore((s) => s.openAddPanel);
if (itemsLoading) {
return (
<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>
);
}
if (!items || items.length === 0) {
return (
<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">
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 (
<>
{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>
);
},
)}
</>
);
}
function PlanningView() {
const [showResolved, setShowResolved] = useState(false);
const [newThreadName, setNewThreadName] = useState("");
const { data: threads, isLoading } = useThreads(showResolved);
const createThread = useCreateThread();
function handleCreateThread(e: React.FormEvent) {
e.preventDefault();
const name = newThreadName.trim();
if (!name) return;
createThread.mutate(
{ name },
{ onSuccess: () => setNewThreadName("") },
);
}
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{[1, 2].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
))}
</div>
);
}
return (
<div>
{/* Create thread form */}
<form onSubmit={handleCreateThread} className="flex gap-2 mb-6">
<input
type="text"
value={newThreadName}
onChange={(e) => setNewThreadName(e.target.value)}
placeholder="New thread 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={!newThreadName.trim() || createThread.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"
>
{createThread.isPending ? "Creating..." : "Create"}
</button>
</form>
{/* Show resolved toggle */}
<label className="flex items-center gap-2 mb-4 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Show archived threads
</label>
{/* Thread list */}
{!threads || threads.length === 0 ? (
<div className="py-12 text-center">
<div className="text-4xl mb-3">🔍</div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">
No planning threads yet
</h3>
<p className="text-sm text-gray-500">
Start one to research your next purchase.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{threads.map((thread) => (
<ThreadCard
key={thread.id}
id={thread.id}
name={thread.name}
candidateCount={thread.candidateCount}
minPriceCents={thread.minPriceCents}
maxPriceCents={thread.maxPriceCents}
createdAt={thread.createdAt}
status={thread.status}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,252 +1,55 @@
import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { useItems } from "../hooks/useItems";
import { useTotals } from "../hooks/useTotals"; import { useTotals } from "../hooks/useTotals";
import { useThreads, useCreateThread } from "../hooks/useThreads"; import { useThreads } from "../hooks/useThreads";
import { CategoryHeader } from "../components/CategoryHeader"; import { useSetups } from "../hooks/useSetups";
import { ItemCard } from "../components/ItemCard"; import { DashboardCard } from "../components/DashboardCard";
import { ThreadTabs } from "../components/ThreadTabs"; import { formatWeight, formatPrice } from "../lib/formatters";
import { ThreadCard } from "../components/ThreadCard";
import { useUIStore } from "../stores/uiStore";
const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"),
});
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
validateSearch: searchSchema, component: DashboardPage,
component: HomePage,
}); });
function HomePage() { function DashboardPage() {
const { tab } = Route.useSearch();
const navigate = useNavigate();
function handleTabChange(newTab: "gear" | "planning") {
navigate({ to: "/", search: { tab: newTab } });
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<ThreadTabs active={tab} onChange={handleTabChange} />
<div className="mt-6">
{tab === "gear" ? <CollectionView /> : <PlanningView />}
</div>
</div>
);
}
function CollectionView() {
const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals(); const { data: totals } = useTotals();
const openAddPanel = useUIStore((s) => s.openAddPanel); const { data: threads } = useThreads(false);
const { data: setups } = useSetups();
if (itemsLoading) { const global = totals?.global;
return ( const activeThreadCount = threads?.length ?? 0;
<div className="animate-pulse space-y-6"> const setupCount = setups?.length ?? 0;
<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>
);
}
if (!items || items.length === 0) {
return (
<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">
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 ( return (
<> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{Array.from(groupedItems.entries()).map( <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
([categoryId, { items: categoryItems, categoryName, categoryEmoji }]) => { <DashboardCard
const catTotals = categoryTotalsMap.get(categoryId); to="/collection"
return ( title="Collection"
<div key={categoryId} className="mb-8"> icon="🎒"
<CategoryHeader stats={[
categoryId={categoryId} { label: "Items", value: String(global?.itemCount ?? 0) },
name={categoryName} { label: "Weight", value: formatWeight(global?.totalWeight ?? null) },
emoji={categoryEmoji} { label: "Cost", value: formatPrice(global?.totalCost ?? null) },
totalWeight={catTotals?.totalWeight ?? 0} ]}
totalCost={catTotals?.totalCost ?? 0} emptyText="Get started"
itemCount={catTotals?.itemCount ?? categoryItems.length}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <DashboardCard
{categoryItems.map((item) => ( to="/collection"
<ItemCard search={{ tab: "planning" }}
key={item.id} title="Planning"
id={item.id} icon="🔍"
name={item.name} stats={[
weightGrams={item.weightGrams} { label: "Active threads", value: String(activeThreadCount) },
priceCents={item.priceCents} ]}
categoryName={categoryName} />
categoryEmoji={categoryEmoji} <DashboardCard
imageFilename={item.imageFilename} to="/setups"
title="Setups"
icon="🏕️"
stats={[
{ label: "Setups", value: String(setupCount) },
]}
/> />
))}
</div> </div>
</div> </div>
); );
},
)}
</>
);
}
function PlanningView() {
const [showResolved, setShowResolved] = useState(false);
const [newThreadName, setNewThreadName] = useState("");
const { data: threads, isLoading } = useThreads(showResolved);
const createThread = useCreateThread();
function handleCreateThread(e: React.FormEvent) {
e.preventDefault();
const name = newThreadName.trim();
if (!name) return;
createThread.mutate(
{ name },
{ onSuccess: () => setNewThreadName("") },
);
}
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{[1, 2].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
))}
</div>
);
}
return (
<div>
{/* Create thread form */}
<form onSubmit={handleCreateThread} className="flex gap-2 mb-6">
<input
type="text"
value={newThreadName}
onChange={(e) => setNewThreadName(e.target.value)}
placeholder="New thread 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={!newThreadName.trim() || createThread.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"
>
{createThread.isPending ? "Creating..." : "Create"}
</button>
</form>
{/* Show resolved toggle */}
<label className="flex items-center gap-2 mb-4 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Show archived threads
</label>
{/* Thread list */}
{!threads || threads.length === 0 ? (
<div className="py-12 text-center">
<div className="text-4xl mb-3">🔍</div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">
No planning threads yet
</h3>
<p className="text-sm text-gray-500">
Start one to research your next purchase.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{threads.map((thread) => (
<ThreadCard
key={thread.id}
id={thread.id}
name={thread.name}
candidateCount={thread.candidateCount}
minPriceCents={thread.minPriceCents}
maxPriceCents={thread.maxPriceCents}
createdAt={thread.createdAt}
status={thread.status}
/>
))}
</div>
)}
</div>
);
} }

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/setups/$setupId")({
component: SetupDetailPlaceholder,
});
function SetupDetailPlaceholder() {
return <div>Setup detail loading...</div>;
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/setups/")({
component: SetupsPlaceholder,
});
function SetupsPlaceholder() {
return <div>Setups loading...</div>;
}

View File

@@ -29,6 +29,15 @@ interface UIState {
openResolveDialog: (threadId: number, candidateId: number) => void; openResolveDialog: (threadId: number, candidateId: number) => void;
closeResolveDialog: () => void; closeResolveDialog: () => void;
// Setup-related UI state
itemPickerOpen: boolean;
openItemPicker: () => void;
closeItemPicker: () => void;
confirmDeleteSetupId: number | null;
openConfirmDeleteSetup: (id: number) => void;
closeConfirmDeleteSetup: () => void;
} }
export const useUIStore = create<UIState>((set) => ({ export const useUIStore = create<UIState>((set) => ({
@@ -67,4 +76,13 @@ export const useUIStore = create<UIState>((set) => ({
set({ resolveThreadId: threadId, resolveCandidateId: candidateId }), set({ resolveThreadId: threadId, resolveCandidateId: candidateId }),
closeResolveDialog: () => closeResolveDialog: () =>
set({ resolveThreadId: null, resolveCandidateId: null }), set({ resolveThreadId: null, resolveCandidateId: null }),
// Setup-related UI state
itemPickerOpen: false,
openItemPicker: () => set({ itemPickerOpen: true }),
closeItemPicker: () => set({ itemPickerOpen: false }),
confirmDeleteSetupId: null,
openConfirmDeleteSetup: (id) => set({ confirmDeleteSetupId: id }),
closeConfirmDeleteSetup: () => set({ confirmDeleteSetupId: null }),
})); }));