'use client'; import { useState, useEffect } from 'react'; import FacetGroup from './FacetGroup'; import type { FacetCounts, FilterState } from '@/lib/types'; interface RecipesSidebarProps { categories: string[]; tags: string[]; facetCounts: FacetCounts; filters: FilterState; onFilterChange: (filters: FilterState) => void; } const COLLAPSED_CATEGORY_COUNT = 8; const COLLAPSED_TAG_COUNT = 10; function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } function ShowMoreButton({ expanded, hiddenCount, onClick }: { expanded: boolean; hiddenCount: number; onClick: () => void }) { return ( {expanded ? 'Show less' : `Show ${hiddenCount} more`} ); } export default function RecipesSidebar({ categories, tags, facetCounts, filters, onFilterChange }: RecipesSidebarProps) { const [searchInput, setSearchInput] = useState(filters.search); const [tagQuery, setTagQuery] = useState(''); const [showAllCategories, setShowAllCategories] = useState(false); const [showAllTags, setShowAllTags] = useState(false); // Sync external search changes (e.g. "Clear all" or URL-driven) to local input useEffect(() => { setSearchInput(filters.search); }, [filters.search]); // Debounce local search input → parent useEffect(() => { const timer = setTimeout(() => { if (searchInput !== filters.search) { onFilterChange({ ...filters, search: searchInput }); } }, 300); return () => clearTimeout(timer); }, [searchInput, filters, onFilterChange]); const handleTagToggle = (tag: string) => { const selectedTags = filters.selectedTags.includes(tag) ? filters.selectedTags.filter((t) => t !== tag) : [...filters.selectedTags, tag]; onFilterChange({ ...filters, selectedTags }); }; const allCategoriesCount = Object.values(facetCounts.categories).reduce((sum, n) => sum + n, 0); // Hide options that would lead to zero results, unless already selected const availableCategories = categories.filter( (cat) => (facetCounts.categories[cat] ?? 0) > 0 || cat === filters.category ); const availableTags = tags.filter( (tag) => ((facetCounts.tags[tag] ?? 0) > 0 || filters.selectedTags.includes(tag)) && tag.toLowerCase().includes(tagQuery.toLowerCase()) ); // Selected values stay visible even when their group is collapsed const visibleCategories = showAllCategories ? availableCategories : availableCategories.filter((cat, i) => i < COLLAPSED_CATEGORY_COUNT || cat === filters.category); const visibleTags = showAllTags || tagQuery ? availableTags : availableTags.filter((tag, i) => i < COLLAPSED_TAG_COUNT || filters.selectedTags.includes(tag)); return ( Search recipes setSearchInput(e.target.value)} placeholder="Search recipes" autoComplete="off" className="w-full rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 py-2 pl-9 pr-9 text-sm text-gray-900 dark:text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 [&::-webkit-search-cancel-button]:appearance-none" /> {searchInput && ( { setSearchInput(''); onFilterChange({ ...filters, search: '' }); }} className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" aria-label="Clear search" > )} Category {[{ value: '', label: 'All categories', count: allCategoriesCount }] .concat(visibleCategories.map((cat) => ({ value: cat, label: capitalize(cat), count: facetCounts.categories[cat] ?? 0, }))) .map(({ value, label, count }) => { const checked = filters.category === value; return ( onFilterChange({ ...filters, category: value })} className="sr-only" /> {label} {count} ); })} {(showAllCategories ? availableCategories.length > COLLAPSED_CATEGORY_COUNT : visibleCategories.length < availableCategories.length) && ( setShowAllCategories(!showAllCategories)} /> )} Tags {tags.length > COLLAPSED_TAG_COUNT && ( Filter tags setTagQuery(e.target.value)} placeholder="Filter tags" autoComplete="off" className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-2.5 py-1.5 text-sm text-gray-900 dark:text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30" /> )} {visibleTags.map((tag) => { const checked = filters.selectedTags.includes(tag); const count = facetCounts.tags[tag] ?? 0; return ( handleTagToggle(tag)} className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 accent-blue-600" /> {tag} {count} ); })} {visibleTags.length === 0 && ( No matching tags )} {!tagQuery && (showAllTags ? availableTags.length > COLLAPSED_TAG_COUNT : visibleTags.length < availableTags.length) && ( setShowAllTags(!showAllTags)} /> )} ); }
No matching tags