"use client" import { IconSubmissionContent } from "@/components/icon-submission-form" import { MagicCard } from "@/components/magicui/magic-card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" 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 { BASE_URL } from "@/constants" import type { Icon, IconSearchProps } from "@/types/icons" import { ArrowDownAZ, ArrowUpZA, Calendar, ChevronLeft, ChevronRight, Filter, Search, SortAsc, X } from "lucide-react" import { useTheme } from "next-themes" import Image from "next/image" import Link from "next/link" import { usePathname, useRouter, useSearchParams } from "next/navigation" import posthog from "posthog-js" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { motion, AnimatePresence } from "framer-motion" type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest" // Get the display rows count based on viewport size function getDefaultRowsPerPage() { if (typeof window === "undefined") return 3; // Default for SSR // Calculate based on viewport height and width const vh = window.innerHeight; const vw = window.innerWidth; // Determine number of columns based on viewport width let columns = 2; // Default for small screens (sm) if (vw >= 1280) columns = 8; // xl breakpoint else if (vw >= 1024) columns = 6; // lg breakpoint else if (vw >= 768) columns = 4; // md breakpoint else if (vw >= 640) columns = 3; // sm breakpoint // Calculate rows (accounting for pagination UI space) const rowHeight = 130; // Approximate height of each row in pixels const availableHeight = vh * 0.6; // 60% of viewport height // Ensure at least 1 row, maximum 5 rows return Math.max(1, Math.min(5, Math.floor(availableHeight / rowHeight))); } export function IconSearch({ icons }: IconSearchProps) { const searchParams = useSearchParams() const initialQuery = searchParams.get("q") const initialCategories = searchParams.getAll("category") const initialSort = (searchParams.get("sort") as SortOption) || "relevance" const initialPage = Number(searchParams.get("page") || "1") 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 [currentPage, setCurrentPage] = useState(initialPage) const [iconsPerPage, setIconsPerPage] = useState(getDefaultRowsPerPage() * 8) // Default cols is 8 for xl screens const timeoutRef = useRef(null) const { resolvedTheme } = useTheme() const [isLazyRequestSubmitted, setIsLazyRequestSubmitted] = useState(false) // Add resize observer to update iconsPerPage when window size changes useEffect(() => { const updateIconsPerPage = () => { const rows = getDefaultRowsPerPage(); // Determine columns based on current viewport const vw = window.innerWidth; let columns = 2; // Default for small screens if (vw >= 1280) columns = 8; // xl breakpoint else if (vw >= 1024) columns = 6; // lg breakpoint else if (vw >= 768) columns = 4; // md breakpoint else if (vw >= 640) columns = 3; // sm breakpoint setIconsPerPage(rows * columns); }; // Initial setup updateIconsPerPage(); // Add resize listener window.addEventListener('resize', updateIconsPerPage); // Cleanup return () => window.removeEventListener('resize', updateIconsPerPage); }, []); // Reset page when search parameters change useEffect(() => { setCurrentPage(1); }, [debouncedQuery, selectedCategories, sortOption]); useEffect(() => { const timer = setTimeout(() => { setDebouncedQuery(searchQuery) }, 200) return () => clearTimeout(timer) }, [searchQuery]) // Extract all unique categories 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]) // Simple filter function using substring matching const filterIcons = useCallback( (query: string, categories: string[], sort: SortOption) => { // First filter by categories if any are selected let filtered = icons if (categories.length > 0) { filtered = filtered.filter(({ data }) => data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())), ) } // Then filter by search query if (query.trim()) { // Normalization function: lowercase, remove spaces and hyphens const normalizeString = (str: string) => str.toLowerCase().replace(/[-\s]/g, "") const normalizedQuery = normalizeString(query) filtered = filtered.filter(({ name, data }) => { // Check normalized name if (normalizeString(name).includes(normalizedQuery)) return true // Check normalized aliases if (data.aliases.some((alias) => normalizeString(alias).includes(normalizedQuery))) return true // Check normalized categories if (data.categories.some((category) => normalizeString(category).includes(normalizedQuery))) return true return false }) } // Apply sorting if (sort === "alphabetical-asc") { return filtered.sort((a, b) => a.name.localeCompare(b.name)) } if (sort === "alphabetical-desc") { return filtered.sort((a, b) => b.name.localeCompare(a.name)) } if (sort === "newest") { return filtered.sort((a, b) => { return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime() }) } // Default sort (relevance or fallback to alphabetical) // TODO: Implement actual relevance sorting return filtered.sort((a, b) => a.name.localeCompare(b.name)) }, [icons], ) // Find matched aliases for display purposes const matchedAliases = useMemo(() => { if (!searchQuery.trim()) return {} const q = searchQuery.toLowerCase() const matches: Record = {} for (const { name, data } of icons) { // If name doesn't match but an alias does, store the first matching alias if (!name.toLowerCase().includes(q)) { const matchingAlias = data.aliases.find((alias) => alias.toLowerCase().includes(q)) if (matchingAlias) { matches[name] = matchingAlias } } } return matches }, [icons, searchQuery]) // Use useMemo for filtered icons with debounced query const filteredIcons = useMemo(() => { return filterIcons(debouncedQuery, selectedCategories, sortOption) }, [filterIcons, debouncedQuery, selectedCategories, sortOption]) const updateResults = useCallback( (query: string, categories: string[], sort: SortOption, page = 1) => { const params = new URLSearchParams() if (query) params.set("q", query) // Clear existing category params and add new ones for (const category of categories) { params.append("category", category) } // Add sort parameter if not default if (sort !== "relevance" || initialSort !== "relevance") { params.set("sort", sort) } // Add page parameter if not the first page if (page > 1) { params.set("page", page.toString()) } 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) // Changed from 100ms to 200ms }, [updateResults, selectedCategories, sortOption], ) const handleCategoryChange = useCallback( (category: string) => { let newCategories: string[] if (selectedCategories.includes(category)) { // Remove the category if it's already selected newCategories = selectedCategories.filter((c) => c !== category) } else { // Add the category if it's not selected 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 handlePageChange = useCallback( (page: number) => { setCurrentPage(page); updateResults(searchQuery, selectedCategories, sortOption, page); }, [updateResults, searchQuery, selectedCategories, sortOption], ) const clearFilters = useCallback(() => { setSearchQuery("") setSelectedCategories([]) setSortOption("relevance") setCurrentPage(1) updateResults("", [], "relevance", 1) }, [updateResults]) useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } } }, []) useEffect(() => { if (filteredIcons.length === 0 && searchQuery) { console.log("no icons found", { query: searchQuery, }) posthog.capture("no icons found", { query: searchQuery, }) } }, [filteredIcons, searchQuery]) if (!searchParams) return null const getSortLabel = (sort: SortOption) => { switch (sort) { case "relevance": return "Relevance" case "alphabetical-asc": return "Name (A-Z)" case "alphabetical-desc": return "Name (Z-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 ( <>
{/* Search input */}
handleSearch(e.target.value)} />
{/* Filter and sort controls */}
{/* Filter dropdown */} 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: focus:bg-rose-50 dark:focus:bg-rose-950/20" > Clear categories )}
{/* Sort dropdown */} Sort Icons handleSortChange(value as SortOption)}> Relevance Name (A-Z) Name (Z-A) Newest first {/* Clear all button */} {(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && ( )}
{/* Active filter badges */} {selectedCategories.length > 0 && (
Selected:
{selectedCategories.map((category) => ( {category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} ))}
)}
{filteredIcons.length === 0 ? (

Icon not found

Help us expand our collection

Can't submit it yourself?
) : ( <>

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

{getSortIcon(sortOption)} {getSortLabel(sortOption)}
)} ) } function IconCard({ name, data: iconData, }: { name: string data: Icon }) { return (
{`${name}
{name.replace(/-/g, " ")}
) } interface IconsGridProps { filteredIcons: { name: string; data: Icon }[] matchedAliases: Record currentPage: number iconsPerPage: number onPageChange: (page: number) => void totalIcons: number } function IconsGrid({ filteredIcons, matchedAliases, currentPage, iconsPerPage, onPageChange, totalIcons }: IconsGridProps) { // Calculate pagination values const totalPages = Math.ceil(totalIcons / iconsPerPage) const indexOfLastIcon = currentPage * iconsPerPage const indexOfFirstIcon = indexOfLastIcon - iconsPerPage const currentIcons = filteredIcons.slice(indexOfFirstIcon, indexOfLastIcon) // Calculate letter ranges for each page const getLetterRange = (pageNum: number) => { if (filteredIcons.length === 0) return ''; const start = (pageNum - 1) * iconsPerPage; const end = Math.min(start + iconsPerPage - 1, filteredIcons.length - 1); if (start >= filteredIcons.length) return ''; const firstLetter = filteredIcons[start].name.charAt(0).toUpperCase(); const lastLetter = filteredIcons[end].name.charAt(0).toUpperCase(); return firstLetter === lastLetter ? firstLetter : `${firstLetter} - ${lastLetter}`; }; // Get current page letter range const currentLetterRange = getLetterRange(currentPage); // Handle direct page input const [pageInput, setPageInput] = useState(currentPage.toString()); useEffect(() => { setPageInput(currentPage.toString()); }, [currentPage]); const handlePageInputChange = (e: React.ChangeEvent) => { setPageInput(e.target.value); }; const handlePageInputSubmit = (e: React.FormEvent) => { e.preventDefault(); const pageNumber = parseInt(pageInput); if (!isNaN(pageNumber) && pageNumber >= 1 && pageNumber <= totalPages) { onPageChange(pageNumber); } else { // Reset to current page if invalid setPageInput(currentPage.toString()); } }; return ( <> {currentIcons.map(({ name, data }) => ( ))} {totalPages > 1 && (
{/* Mobile view: centered content */}
Showing {indexOfFirstIcon + 1}-{Math.min(indexOfLastIcon, totalIcons)} of {totalIcons} icons {currentLetterRange && ( ({currentLetterRange}) )}
{/* Desktop view layout */}
Showing {indexOfFirstIcon + 1}-{Math.min(indexOfLastIcon, totalIcons)} of {totalIcons} icons {currentLetterRange && ( ({currentLetterRange}) )}
{/* Page input and total count */}
of {totalPages}
{/* Pagination controls */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { // Show pages around current page let pageNum; if (totalPages <= 5) { pageNum = i + 1; } else if (currentPage <= 3) { pageNum = i + 1; } else if (currentPage >= totalPages - 2) { pageNum = totalPages - 4 + i; } else { pageNum = currentPage - 2 + i; } // Calculate letter range for this page const letterRange = getLetterRange(pageNum); return ( ); })}
{/* Mobile-only pagination layout - centered */}
{/* Mobile pagination controls */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { // Show pages around current page - same logic as desktop let pageNum; if (totalPages <= 5) { pageNum = i + 1; } else if (currentPage <= 3) { pageNum = i + 1; } else if (currentPage >= totalPages - 2) { pageNum = totalPages - 4 + i; } else { pageNum = currentPage - 2 + i; } return ( ); })}
{/* Mobile page input */}
of {totalPages}
)} ) }