diff --git a/src/client/components/CollectionView.tsx b/src/client/components/CollectionView.tsx new file mode 100644 index 0000000..4a9e258 --- /dev/null +++ b/src/client/components/CollectionView.tsx @@ -0,0 +1,277 @@ +import { useMemo, useState } from "react"; +import { useCategories } from "../hooks/useCategories"; +import { useCurrency } from "../hooks/useCurrency"; +import { useItems } from "../hooks/useItems"; +import { useTotals } from "../hooks/useTotals"; +import { useWeightUnit } from "../hooks/useWeightUnit"; +import { formatPrice, formatWeight } from "../lib/formatters"; +import { LucideIcon } from "../lib/iconData"; +import { useUIStore } from "../stores/uiStore"; +import { CategoryFilterDropdown } from "./CategoryFilterDropdown"; +import { CategoryHeader } from "./CategoryHeader"; +import { ItemCard } from "./ItemCard"; + +export function CollectionView() { + const { data: items, isLoading: itemsLoading } = useItems(); + const { data: totals } = useTotals(); + const { data: categories } = useCategories(); + const unit = useWeightUnit(); + const currency = useCurrency(); + const openAddPanel = useUIStore((s) => s.openAddPanel); + + const [searchText, setSearchText] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(null); + + const filteredItems = useMemo(() => { + if (!items) return []; + return items.filter((item) => { + const matchesSearch = + searchText === "" || + item.name.toLowerCase().includes(searchText.toLowerCase()); + const matchesCategory = + categoryFilter === null || item.categoryId === categoryFilter; + return matchesSearch && matchesCategory; + }); + }, [items, searchText, categoryFilter]); + + const hasActiveFilters = searchText !== "" || categoryFilter !== null; + + if (itemsLoading) { + return ( +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + } + + if (!items || items.length === 0) { + return ( +
+
+
+ +
+

+ Your collection is empty +

+

+ Start cataloging your gear by adding your first item. Track weight, + price, and organize by category. +

+ +
+
+ ); + } + + // 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, + }); + } + } + + // Group filtered items by categoryId (used when no active filters) + const groupedItems = new Map< + number, + { + items: typeof filteredItems; + categoryName: string; + categoryIcon: string; + } + >(); + + for (const item of filteredItems) { + const group = groupedItems.get(item.categoryId); + if (group) { + group.items.push(item); + } else { + groupedItems.set(item.categoryId, { + items: [item], + categoryName: item.categoryName, + categoryIcon: item.categoryIcon, + }); + } + } + + return ( + <> + {/* Collection stats card */} + {totals?.global && ( +
+
+
+ + Items + + {totals.global.itemCount} + +
+
+ + Total Weight + + {formatWeight(totals.global.totalWeight, unit)} + +
+
+ + Total Spent + + {formatPrice(totals.global.totalCost, currency)} + +
+
+
+ )} + + {/* Search/filter toolbar */} +
+
+
+ setSearchText(e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + /> + {searchText && ( + + )} +
+ +
+ {hasActiveFilters && ( +

+ Showing {filteredItems.length} of {items.length} items +

+ )} +
+ + {/* Filtered results */} + {hasActiveFilters ? ( + filteredItems.length === 0 ? ( +
+

No items match your search

+
+ ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ ) + ) : ( + Array.from(groupedItems.entries()).map( + ([ + categoryId, + { items: categoryItems, categoryName, categoryIcon }, + ]) => { + const catTotals = categoryTotalsMap.get(categoryId); + return ( +
+ +
+ {categoryItems.map((item) => ( + + ))} +
+
+ ); + }, + ) + )} + + ); +} diff --git a/src/client/components/PlanningView.tsx b/src/client/components/PlanningView.tsx new file mode 100644 index 0000000..acf1cf4 --- /dev/null +++ b/src/client/components/PlanningView.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { useCategories } from "../hooks/useCategories"; +import { useThreads } from "../hooks/useThreads"; +import { useUIStore } from "../stores/uiStore"; +import { CategoryFilterDropdown } from "./CategoryFilterDropdown"; +import { CreateThreadModal } from "./CreateThreadModal"; +import { ThreadCard } from "./ThreadCard"; + +export function PlanningView() { + const [activeTab, setActiveTab] = useState<"active" | "resolved">("active"); + const [categoryFilter, setCategoryFilter] = useState(null); + + const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal); + const { data: categories } = useCategories(); + const { data: threads, isLoading } = useThreads(activeTab === "resolved"); + + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ); + } + + // Filter threads by active tab and category + const filteredThreads = (threads ?? []) + .filter((t) => t.status === activeTab) + .filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true)); + + // Determine if we should show the educational empty state + const isEmptyNoFilters = + filteredThreads.length === 0 && + activeTab === "active" && + categoryFilter === null && + (!threads || threads.length === 0); + + return ( +
+ {/* Header row */} +
+

+ Planning Threads +

+ +
+ + {/* Filter row */} +
+ {/* Pill tabs */} +
+ + +
+ + {/* Category filter */} + +
+ + {/* Content: empty state or thread grid */} + {isEmptyNoFilters ? ( +
+
+

+ Plan your next purchase +

+
+
+
+ 1 +
+
+

Create a thread

+

+ Start a research thread for gear you're considering +

+
+
+
+
+ 2 +
+
+

Add candidates

+

+ Add products you're comparing with prices and weights +

+
+
+
+
+ 3 +
+
+

Pick a winner

+

+ Resolve the thread and the winner joins your collection +

+
+
+
+ +
+
+ ) : filteredThreads.length === 0 ? ( +
+

No threads found

+
+ ) : ( +
+ {filteredThreads.map((thread) => ( + + ))} +
+ )} + + +
+ ); +} diff --git a/src/client/components/SetupsView.tsx b/src/client/components/SetupsView.tsx new file mode 100644 index 0000000..d9a28d9 --- /dev/null +++ b/src/client/components/SetupsView.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { useCreateSetup, useSetups } from "../hooks/useSetups"; +import { SetupCard } from "./SetupCard"; + +export function SetupsView() { + 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-gray-400 focus:border-transparent" + /> + +
+ + {/* Loading skeleton */} + {isLoading && ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ )} + + {/* Empty state */} + {!isLoading && (!setups || setups.length === 0) && ( +
+
+

+ Build your perfect loadout +

+
+
+
+ 1 +
+
+

Create a setup

+

+ Name your loadout for a specific trip or activity +

+
+
+
+
+ 2 +
+
+

Add items

+

+ Pick gear from your collection to include in the setup +

+
+
+
+
+ 3 +
+
+

Track weight

+

+ See weight breakdown and optimize your pack +

+
+
+
+
+
+ )} + + {/* Setup grid */} + {!isLoading && setups && setups.length > 0 && ( +
+ {setups.map((setup) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/client/routes/collection/index.tsx b/src/client/routes/collection/index.tsx index 846739e..a12cf15 100644 --- a/src/client/routes/collection/index.tsx +++ b/src/client/routes/collection/index.tsx @@ -1,23 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; -import { useMemo, useRef, useState } from "react"; +import { useRef } from "react"; import { z } from "zod"; -import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown"; -import { CategoryHeader } from "../../components/CategoryHeader"; -import { CreateThreadModal } from "../../components/CreateThreadModal"; -import { ItemCard } from "../../components/ItemCard"; -import { SetupCard } from "../../components/SetupCard"; -import { ThreadCard } from "../../components/ThreadCard"; -import { useCategories } from "../../hooks/useCategories"; -import { useCurrency } from "../../hooks/useCurrency"; -import { useItems } from "../../hooks/useItems"; -import { useCreateSetup, useSetups } from "../../hooks/useSetups"; -import { useThreads } from "../../hooks/useThreads"; -import { useTotals } from "../../hooks/useTotals"; -import { useWeightUnit } from "../../hooks/useWeightUnit"; -import { formatPrice, formatWeight } from "../../lib/formatters"; -import { LucideIcon } from "../../lib/iconData"; -import { useUIStore } from "../../stores/uiStore"; +import { CollectionView } from "../../components/CollectionView"; +import { PlanningView } from "../../components/PlanningView"; +import { SetupsView } from "../../components/SetupsView"; const searchSchema = z.object({ tab: z.enum(["gear", "planning", "setups"]).catch("gear"), @@ -68,566 +55,3 @@ function CollectionPage() {
); } - -function CollectionView() { - const { data: items, isLoading: itemsLoading } = useItems(); - const { data: totals } = useTotals(); - const { data: categories } = useCategories(); - const unit = useWeightUnit(); - const currency = useCurrency(); - const openAddPanel = useUIStore((s) => s.openAddPanel); - - const [searchText, setSearchText] = useState(""); - const [categoryFilter, setCategoryFilter] = useState(null); - - const filteredItems = useMemo(() => { - if (!items) return []; - return items.filter((item) => { - const matchesSearch = - searchText === "" || - item.name.toLowerCase().includes(searchText.toLowerCase()); - const matchesCategory = - categoryFilter === null || item.categoryId === categoryFilter; - return matchesSearch && matchesCategory; - }); - }, [items, searchText, categoryFilter]); - - const hasActiveFilters = searchText !== "" || categoryFilter !== null; - - if (itemsLoading) { - return ( -
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
- ); - } - - if (!items || items.length === 0) { - return ( -
-
-
- -
-

- Your collection is empty -

-

- Start cataloging your gear by adding your first item. Track weight, - price, and organize by category. -

- -
-
- ); - } - - // 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, - }); - } - } - - // Group filtered items by categoryId (used when no active filters) - const groupedItems = new Map< - number, - { - items: typeof filteredItems; - categoryName: string; - categoryIcon: string; - } - >(); - - for (const item of filteredItems) { - const group = groupedItems.get(item.categoryId); - if (group) { - group.items.push(item); - } else { - groupedItems.set(item.categoryId, { - items: [item], - categoryName: item.categoryName, - categoryIcon: item.categoryIcon, - }); - } - } - - return ( - <> - {/* Collection stats card */} - {totals?.global && ( -
-
-
- - Items - - {totals.global.itemCount} - -
-
- - Total Weight - - {formatWeight(totals.global.totalWeight, unit)} - -
-
- - Total Spent - - {formatPrice(totals.global.totalCost, currency)} - -
-
-
- )} - - {/* Search/filter toolbar */} -
-
-
- setSearchText(e.target.value)} - className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" - /> - {searchText && ( - - )} -
- -
- {hasActiveFilters && ( -

- Showing {filteredItems.length} of {items.length} items -

- )} -
- - {/* Filtered results */} - {hasActiveFilters ? ( - filteredItems.length === 0 ? ( -
-

No items match your search

-
- ) : ( -
- {filteredItems.map((item) => ( - - ))} -
- ) - ) : ( - Array.from(groupedItems.entries()).map( - ([ - categoryId, - { items: categoryItems, categoryName, categoryIcon }, - ]) => { - const catTotals = categoryTotalsMap.get(categoryId); - return ( -
- -
- {categoryItems.map((item) => ( - - ))} -
-
- ); - }, - ) - )} - - ); -} - -function PlanningView() { - const [activeTab, setActiveTab] = useState<"active" | "resolved">("active"); - const [categoryFilter, setCategoryFilter] = useState(null); - - const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal); - const { data: categories } = useCategories(); - const { data: threads, isLoading } = useThreads(activeTab === "resolved"); - - if (isLoading) { - return ( -
- {[1, 2].map((i) => ( -
- ))} -
- ); - } - - // Filter threads by active tab and category - const filteredThreads = (threads ?? []) - .filter((t) => t.status === activeTab) - .filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true)); - - // Determine if we should show the educational empty state - const isEmptyNoFilters = - filteredThreads.length === 0 && - activeTab === "active" && - categoryFilter === null && - (!threads || threads.length === 0); - - return ( -
- {/* Header row */} -
-

- Planning Threads -

- -
- - {/* Filter row */} -
- {/* Pill tabs */} -
- - -
- - {/* Category filter */} - -
- - {/* Content: empty state or thread grid */} - {isEmptyNoFilters ? ( -
-
-

- Plan your next purchase -

-
-
-
- 1 -
-
-

Create a thread

-

- Start a research thread for gear you're considering -

-
-
-
-
- 2 -
-
-

Add candidates

-

- Add products you're comparing with prices and weights -

-
-
-
-
- 3 -
-
-

Pick a winner

-

- Resolve the thread and the winner joins your collection -

-
-
-
- -
-
- ) : filteredThreads.length === 0 ? ( -
-

No threads found

-
- ) : ( -
- {filteredThreads.map((thread) => ( - - ))} -
- )} - - -
- ); -} - -function SetupsView() { - 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-gray-400 focus:border-transparent" - /> - -
- - {/* Loading skeleton */} - {isLoading && ( -
- {[1, 2].map((i) => ( -
- ))} -
- )} - - {/* Empty state */} - {!isLoading && (!setups || setups.length === 0) && ( -
-
-

- Build your perfect loadout -

-
-
-
- 1 -
-
-

Create a setup

-

- Name your loadout for a specific trip or activity -

-
-
-
-
- 2 -
-
-

Add items

-

- Pick gear from your collection to include in the setup -

-
-
-
-
- 3 -
-
-

Track weight

-

- See weight breakdown and optimize your pack -

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