feat(08-02): add search/filter toolbar to gear tab and upgrade planning filter
- Sticky search/filter toolbar with text input and CategoryFilterDropdown - useMemo-based filtering by name (search) and categoryId (dropdown) - "Showing X of Y items" count when filters active - Flat grid (no category headers) when any filter is active - "No items match your search" empty state for filtered results - Replace PlanningView native select with CategoryFilterDropdown
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
|
||||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||||
import { CreateThreadModal } from "../../components/CreateThreadModal";
|
import { CreateThreadModal } from "../../components/CreateThreadModal";
|
||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
@@ -51,8 +52,26 @@ function CollectionPage() {
|
|||||||
function CollectionView() {
|
function CollectionView() {
|
||||||
const { data: items, isLoading: itemsLoading } = useItems();
|
const { data: items, isLoading: itemsLoading } = useItems();
|
||||||
const { data: totals } = useTotals();
|
const { data: totals } = useTotals();
|
||||||
|
const { data: categories } = useCategories();
|
||||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(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) {
|
if (itemsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse space-y-6">
|
<div className="animate-pulse space-y-6">
|
||||||
@@ -110,25 +129,6 @@ function CollectionView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group items by categoryId
|
|
||||||
const groupedItems = new Map<
|
|
||||||
number,
|
|
||||||
{ items: typeof items; categoryName: string; categoryIcon: 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,
|
|
||||||
categoryIcon: item.categoryIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build category totals lookup
|
// Build category totals lookup
|
||||||
const categoryTotalsMap = new Map<
|
const categoryTotalsMap = new Map<
|
||||||
number,
|
number,
|
||||||
@@ -144,42 +144,138 @@ function CollectionView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{Array.from(groupedItems.entries()).map(
|
{/* Search/filter toolbar */}
|
||||||
([
|
<div className="sticky top-0 z-10 bg-white/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
||||||
categoryId,
|
<div className="flex gap-3 items-center">
|
||||||
{ items: categoryItems, categoryName, categoryIcon },
|
<div className="relative flex-1">
|
||||||
]) => {
|
<input
|
||||||
const catTotals = categoryTotalsMap.get(categoryId);
|
type="text"
|
||||||
return (
|
placeholder="Search items..."
|
||||||
<div key={categoryId} className="mb-8">
|
value={searchText}
|
||||||
<CategoryHeader
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
categoryId={categoryId}
|
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"
|
||||||
name={categoryName}
|
/>
|
||||||
icon={categoryIcon}
|
{searchText && (
|
||||||
totalWeight={catTotals?.totalWeight ?? 0}
|
<button
|
||||||
totalCost={catTotals?.totalCost ?? 0}
|
type="button"
|
||||||
itemCount={catTotals?.itemCount ?? categoryItems.length}
|
onClick={() => setSearchText("")}
|
||||||
/>
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
>
|
||||||
{categoryItems.map((item) => (
|
<svg
|
||||||
<ItemCard
|
className="w-4 h-4"
|
||||||
key={item.id}
|
fill="none"
|
||||||
id={item.id}
|
stroke="currentColor"
|
||||||
name={item.name}
|
viewBox="0 0 24 24"
|
||||||
weightGrams={item.weightGrams}
|
>
|
||||||
priceCents={item.priceCents}
|
<path
|
||||||
categoryName={categoryName}
|
strokeLinecap="round"
|
||||||
categoryIcon={categoryIcon}
|
strokeLinejoin="round"
|
||||||
imageFilename={item.imageFilename}
|
strokeWidth={2}
|
||||||
productUrl={item.productUrl}
|
d="M6 18L18 6M6 6l12 12"
|
||||||
/>
|
/>
|
||||||
))}
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CategoryFilterDropdown
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
categories={categories ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Showing {filteredItems.length} of {items.length} items
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtered results */}
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
filteredItems.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
No items match your search
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
weightGrams={item.weightGrams}
|
||||||
|
priceCents={item.priceCents}
|
||||||
|
categoryName={item.categoryName}
|
||||||
|
categoryIcon={item.categoryIcon}
|
||||||
|
imageFilename={item.imageFilename}
|
||||||
|
productUrl={item.productUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
Array.from(groupedItems.entries()).map(
|
||||||
|
([
|
||||||
|
categoryId,
|
||||||
|
{ items: categoryItems, categoryName, categoryIcon },
|
||||||
|
]) => {
|
||||||
|
const catTotals = categoryTotalsMap.get(categoryId);
|
||||||
|
return (
|
||||||
|
<div key={categoryId} className="mb-8">
|
||||||
|
<CategoryHeader
|
||||||
|
categoryId={categoryId}
|
||||||
|
name={categoryName}
|
||||||
|
icon={categoryIcon}
|
||||||
|
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}
|
||||||
|
categoryIcon={categoryIcon}
|
||||||
|
imageFilename={item.imageFilename}
|
||||||
|
productUrl={item.productUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
},
|
||||||
},
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -274,20 +370,11 @@ function PlanningView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category filter */}
|
{/* Category filter */}
|
||||||
<select
|
<CategoryFilterDropdown
|
||||||
value={categoryFilter ?? ""}
|
value={categoryFilter}
|
||||||
onChange={(e) =>
|
onChange={setCategoryFilter}
|
||||||
setCategoryFilter(e.target.value ? Number(e.target.value) : null)
|
categories={categories ?? []}
|
||||||
}
|
/>
|
||||||
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">All categories</option>
|
|
||||||
{categories?.map((cat) => (
|
|
||||||
<option key={cat.id} value={cat.id}>
|
|
||||||
{cat.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content: empty state or thread grid */}
|
{/* Content: empty state or thread grid */}
|
||||||
|
|||||||
Reference in New Issue
Block a user