From 07f196f12f097f1cc19cf7935a2e1c907c9aa653 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 1 Oct 2025 18:23:19 +0200 Subject: [PATCH] feat(web): add community icon search component Add comprehensive search and filter component for community-submitted icons. Features include real-time search with debouncing, category filtering, multiple sort options (relevance, A-Z, Z-A, newest), and grouped display by submission status. Integrates with URL query parameters for shareable filtered views --- web/src/components/community-icon-search.tsx | 428 +++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 web/src/components/community-icon-search.tsx diff --git a/web/src/components/community-icon-search.tsx b/web/src/components/community-icon-search.tsx new file mode 100644 index 00000000..5eb6c44c --- /dev/null +++ b/web/src/components/community-icon-search.tsx @@ -0,0 +1,428 @@ +"use client" + +import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { IconsGrid } from "@/components/icon-grid" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { filterAndSortIcons, type SortOption } from "@/lib/utils" +import type { IconWithName } from "@/types/icons" + +type IconWithStatus = IconWithName & { status: string } + +interface CommunityIconSearchProps { + icons: IconWithStatus[] +} + +const getStatusColor = (status: string) => { + switch (status) { + case "approved": + return "bg-blue-500/10 text-blue-400 font-bold border-blue-500/20" + case "rejected": + return "bg-red-500/10 text-red-500 border-red-500/20" + case "pending": + return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" + case "added_to_collection": + return "bg-green-500/10 text-green-500 border-green-500/20" + default: + return "bg-gray-500/10 text-gray-500 border-gray-500/20" + } +} + +const getStatusDisplayName = (status: string) => { + switch (status) { + case "pending": + return "Pending Review" + case "approved": + return "Approved" + case "rejected": + return "Rejected" + case "added_to_collection": + return "Added to Collection" + default: + return status + } +} + +export function CommunityIconSearch({ icons }: CommunityIconSearchProps) { + const searchParams = useSearchParams() + const initialQuery = searchParams.get("q") + const initialCategories = searchParams.getAll("category") + const initialSort = (searchParams.get("sort") as SortOption) || "relevance" + const router = useRouter() + const pathname = usePathname() + const [searchQuery, setSearchQuery] = useState(initialQuery ?? "") + const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "") + const [selectedCategories, setSelectedCategories] = useState(initialCategories ?? []) + const [sortOption, setSortOption] = useState(initialSort) + const timeoutRef = useRef(null) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery) + }, 200) + + return () => clearTimeout(timer) + }, [searchQuery]) + + const allCategories = useMemo(() => { + const categories = new Set() + for (const icon of icons) { + for (const category of icon.data.categories) { + categories.add(category) + } + } + return Array.from(categories).sort() + }, [icons]) + + const matchedAliases = useMemo(() => { + if (!searchQuery.trim()) return {} + + const q = searchQuery.toLowerCase() + const matches: Record = {} + + for (const { name, data } of icons) { + if (!name.toLowerCase().includes(q)) { + const matchingAlias = data.aliases.find((alias) => alias.toLowerCase().includes(q)) + if (matchingAlias) { + matches[name] = matchingAlias + } + } + } + + return matches + }, [icons, searchQuery]) + + const filteredIcons = useMemo(() => { + const result = filterAndSortIcons({ + icons, + query: debouncedQuery, + categories: selectedCategories, + sort: sortOption, + }) as IconWithStatus[] + + return result + }, [icons, debouncedQuery, selectedCategories, sortOption]) + + const groupedIcons = useMemo(() => { + const statusPriority = { pending: 0, approved: 1, rejected: 2, added_to_collection: 3 } + + const groups: Record = {} + + for (const icon of filteredIcons) { + const iconWithStatus = icon as IconWithStatus + const status = iconWithStatus.status || 'pending' + + if (!groups[status]) { + groups[status] = [] + } + groups[status].push(iconWithStatus) + } + + return Object.entries(groups) + .sort(([a], [b]) => { + return (statusPriority[a as keyof typeof statusPriority] ?? 999) - + (statusPriority[b as keyof typeof statusPriority] ?? 999) + }) + .map(([status, items]) => ({ status, items })) + }, [filteredIcons]) + + const updateResults = useCallback( + (query: string, categories: string[], sort: SortOption) => { + const params = new URLSearchParams() + if (query) params.set("q", query) + + for (const category of categories) { + params.append("category", category) + } + + if (sort !== "relevance" || initialSort !== "relevance") { + params.set("sort", sort) + } + + const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname + router.push(newUrl, { scroll: false }) + }, + [pathname, router, initialSort], + ) + + const handleSearch = useCallback( + (query: string) => { + setSearchQuery(query) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + timeoutRef.current = setTimeout(() => { + updateResults(query, selectedCategories, sortOption) + }, 200) + }, + [updateResults, selectedCategories, sortOption], + ) + + const handleCategoryChange = useCallback( + (category: string) => { + let newCategories: string[] + + if (selectedCategories.includes(category)) { + newCategories = selectedCategories.filter((c) => c !== category) + } else { + newCategories = [...selectedCategories, category] + } + + setSelectedCategories(newCategories) + updateResults(searchQuery, newCategories, sortOption) + }, + [updateResults, searchQuery, selectedCategories, sortOption], + ) + + const handleSortChange = useCallback( + (sort: SortOption) => { + setSortOption(sort) + updateResults(searchQuery, selectedCategories, sort) + }, + [updateResults, searchQuery, selectedCategories], + ) + + const clearFilters = useCallback(() => { + setSearchQuery("") + setSelectedCategories([]) + setSortOption("relevance") + updateResults("", [], "relevance") + }, [updateResults]) + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + if (!searchParams) return null + + const getSortLabel = (sort: SortOption) => { + switch (sort) { + case "relevance": + return "Best match" + case "alphabetical-asc": + return "A to Z" + case "alphabetical-desc": + return "Z to A" + case "newest": + return "Newest first" + default: + return "Sort" + } + } + + const getSortIcon = (sort: SortOption) => { + switch (sort) { + case "relevance": + return + case "alphabetical-asc": + return + case "alphabetical-desc": + return + case "newest": + return + default: + return + } + } + + return ( + <> +
+
+
+ +
+ handleSearch(e.target.value)} + /> +
+ +
+ + + + + + Select Categories + + +
+ {allCategories.map((category) => ( + handleCategoryChange(category)} + className="cursor-pointer capitalize" + > + {category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} + + ))} +
+ + {selectedCategories.length > 0 && ( + <> + + { + setSelectedCategories([]) + updateResults(searchQuery, [], sortOption) + }} + className="cursor-pointer focus:bg-rose-50 dark:focus:bg-rose-950/20" + > + Clear categories + + + )} +
+
+ + + + + + + Sort By + + handleSortChange(value as SortOption)}> + + + Relevance + + + + Name (A-Z) + + + + Name (Z-A) + + + + Newest first + + + + + + {(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && ( + + )} +
+ + {selectedCategories.length > 0 && ( +
+ Selected: +
+ {selectedCategories.map((category) => ( + + {category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} + + + ))} +
+ + +
+ )} + + +
+ + {filteredIcons.length === 0 ? ( +
+
+

No icons found

+

Try adjusting your search or filters

+
+
+ ) : ( +
+
+

+ Found {filteredIcons.length} icon + {filteredIcons.length !== 1 ? "s" : ""}. +

+
+ {getSortIcon(sortOption)} + {getSortLabel(sortOption)} +
+
+ + {groupedIcons.map(({ status, items }) => ( +
+
+ + {getStatusDisplayName(status)} + + + {items.length} {items.length === 1 ? 'icon' : 'icons'} + +
+ + + + + +
+ ))} +
+ )} + + ) +} +