From eb799c3637a6ca62eb9c241915e890de66b10eb1 Mon Sep 17 00:00:00 2001 From: Bjorn Lammers Date: Thu, 24 Apr 2025 14:55:52 +0200 Subject: [PATCH] feat(web): Add dynamic pagination to IconSearch component --- web/src/app/icons/components/icon-search.tsx | 343 ++++++++++++++++++- 1 file changed, 327 insertions(+), 16 deletions(-) diff --git a/web/src/app/icons/components/icon-search.tsx b/web/src/app/icons/components/icon-search.tsx index 8639a582..bfa7c758 100644 --- a/web/src/app/icons/components/icon-search.tsx +++ b/web/src/app/icons/components/icon-search.tsx @@ -19,7 +19,7 @@ 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, Filter, Search, SortAsc, X } from "lucide-react" +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" @@ -27,24 +27,82 @@ 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) @@ -138,7 +196,7 @@ export function IconSearch({ icons }: IconSearchProps) { }, [filterIcons, debouncedQuery, selectedCategories, sortOption]) const updateResults = useCallback( - (query: string, categories: string[], sort: SortOption) => { + (query: string, categories: string[], sort: SortOption, page = 1) => { const params = new URLSearchParams() if (query) params.set("q", query) @@ -152,6 +210,11 @@ export function IconSearch({ icons }: IconSearchProps) { 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 }) }, @@ -197,11 +260,20 @@ export function IconSearch({ icons }: IconSearchProps) { [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") - updateResults("", [], "relevance") + setCurrentPage(1) + updateResults("", [], "relevance", 1) }, [updateResults]) useEffect(() => { @@ -435,7 +507,14 @@ export function IconSearch({ icons }: IconSearchProps) { - + )} @@ -445,15 +524,13 @@ export function IconSearch({ icons }: IconSearchProps) { function IconCard({ name, data: iconData, - matchedAlias, }: { name: string data: Icon - matchedAlias?: string | null }) { return ( - - + +
{name.replace(/-/g, " ")} - - {matchedAlias && Alias: {matchedAlias}} ) @@ -475,17 +550,253 @@ function IconCard({ interface IconsGridProps { filteredIcons: { name: string; data: Icon }[] matchedAliases: Record + currentPage: number + iconsPerPage: number + onPageChange: (page: number) => void + totalIcons: number } -function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) { +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 ( <> -
- {filteredIcons.slice(0, 120).map(({ name, data }) => ( - - ))} + + + {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} + +
+
- {filteredIcons.length > 120 &&

And {filteredIcons.length - 120} more...

} + )} ) }