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 { useState } from "react";
|
||||
import { useMemo, useState } 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";
|
||||
@@ -51,8 +52,26 @@ function CollectionPage() {
|
||||
function CollectionView() {
|
||||
const { data: items, isLoading: itemsLoading } = useItems();
|
||||
const { data: totals } = useTotals();
|
||||
const { data: categories } = useCategories();
|
||||
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) {
|
||||
return (
|
||||
<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
|
||||
const categoryTotalsMap = new Map<
|
||||
number,
|
||||
@@ -144,9 +144,104 @@ 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 (
|
||||
<>
|
||||
{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">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search items..."
|
||||
value={searchText}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
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 },
|
||||
@@ -180,6 +275,7 @@ function CollectionView() {
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -274,20 +370,11 @@ function PlanningView() {
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<select
|
||||
value={categoryFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
setCategoryFilter(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
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>
|
||||
<CategoryFilterDropdown
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
categories={categories ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content: empty state or thread grid */}
|
||||
|
||||
Reference in New Issue
Block a user