From 0489d1b1272215ee09a20f14defe3c8d3b63452b Mon Sep 17 00:00:00 2001 From: taroj1205 Date: Fri, 31 Jan 2025 08:54:24 +1300 Subject: [PATCH] feat(pagination): add pagination and search param for mods page --- src/components/ModsList.tsx | 179 ++++++++++++++++++++++++------------ src/hooks/useModsSearch.ts | 177 +++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useModsSearch.ts diff --git a/src/components/ModsList.tsx b/src/components/ModsList.tsx index 39517112..719a69b9 100644 --- a/src/components/ModsList.tsx +++ b/src/components/ModsList.tsx @@ -1,7 +1,9 @@ -import React, { useState, useMemo } from 'react' +import type React from 'react' +import { useState, useEffect } from 'react' import type { ZenTheme } from '../mods' import { library, icon } from '@fortawesome/fontawesome-svg-core' import { faSort, faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons' +import { useModsSearch } from '../hooks/useModsSearch' // Add icons to the library library.add(faSort, faSortUp, faSortDown) @@ -16,29 +18,29 @@ interface ModsListProps { } export default function ModsList({ mods }: ModsListProps) { - const [search, setSearch] = useState('') - const [createdSort, setCreatedSort] = useState<'default' | 'asc' | 'desc'>( - 'default', - ) - const [updatedSort, setUpdatedSort] = useState<'default' | 'asc' | 'desc'>( - 'default', - ) - - const toggleCreatedSort = () => { - setCreatedSort((prev) => { - if (prev === 'default') return 'asc' - if (prev === 'asc') return 'desc' - return 'default' - }) - } - - const toggleUpdatedSort = () => { - setUpdatedSort((prev) => { - if (prev === 'default') return 'asc' - if (prev === 'asc') return 'desc' - return 'default' - }) - } + const { + search, + createdSort, + updatedSort, + page, + limit, + totalPages, + totalItems, + setSearch, + toggleCreatedSort, + toggleUpdatedSort, + setPage, + setLimit, + mods: paginatedMods, + searchParams, + } = useModsSearch(mods) + + const [pageInput, setPageInput] = useState(page.toString()) + + // Keep page input in sync with actual page + useEffect(() => { + setPageInput(page.toString()) + }, [page]) function getSortIcon(state: 'default' | 'asc' | 'desc') { if (state === 'asc') return ascSortIcon @@ -46,42 +48,85 @@ export default function ModsList({ mods }: ModsListProps) { return defaultSortIcon } - const filteredAndSortedMods = useMemo(() => { - let filtered = [...mods] - - // Filter by search - const searchTerm = search.toLowerCase() - if (searchTerm) { - filtered = filtered.filter( - (mod) => - mod.name.toLowerCase().includes(searchTerm) || - mod.description.toLowerCase().includes(searchTerm) || - mod.author.toLowerCase().includes(searchTerm) || - (mod.tags?.some((tag) => tag.toLowerCase().includes(searchTerm)) ?? - false), - ) + function handleSearch(e: React.ChangeEvent) { + setSearch(e.target.value) + } + + function handleLimitChange(e: React.ChangeEvent) { + setLimit(Number.parseInt(e.target.value, 10)) + } + + function handlePageSubmit(e: React.FormEvent) { + e.preventDefault() + const newPage = Number.parseInt(pageInput, 10) + if (!Number.isNaN(newPage) && newPage >= 1 && newPage <= totalPages) { + setPage(newPage) + window.scrollTo(0, 0) + } else { + setPageInput(page.toString()) } + } + + function handlePageInputChange(e: React.ChangeEvent) { + setPageInput(e.target.value) + } - // Sort by createdAt if chosen - if (createdSort !== 'default') { - filtered.sort((a, b) => { - const diff = - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - return createdSort === 'asc' ? diff : -diff - }) + function getPageUrl(pageNum: number) { + let link = '/mods' + + if (pageNum > 1) { + link += `?page=${pageNum}` } - // Sort by updatedAt if chosen - if (updatedSort !== 'default') { - filtered.sort((a, b) => { - const diff = - new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - return updatedSort === 'asc' ? diff : -diff - }) + if (searchParams) { + link += `&${searchParams.toString()}` } - return filtered - }, [mods, search, createdSort, updatedSort]) + return link + } + + function renderPagination() { + if (totalPages <= 1) return null + + // Render page input for larger page counts + return ( +
+ + < + +
+ Page + + + of {totalPages} ({totalItems} items) + +
+ + > + +
+ ) + } return (
@@ -93,11 +138,11 @@ export default function ModsList({ mods }: ModsListProps) { className="w-full rounded-full border-2 border-dark bg-transparent px-6 py-2 text-lg outline-none" placeholder="Type to search..." value={search} - onChange={(e) => setSearch(e.target.value)} + onChange={handleSearch} />
-
+
+ +
+ + +
- {filteredAndSortedMods.map((mod) => ( + {paginatedMods.map((mod) => (
@@ -156,6 +219,8 @@ export default function ModsList({ mods }: ModsListProps) { ))} + + {renderPagination()} ) } diff --git a/src/hooks/useModsSearch.ts b/src/hooks/useModsSearch.ts new file mode 100644 index 00000000..1f3843f8 --- /dev/null +++ b/src/hooks/useModsSearch.ts @@ -0,0 +1,177 @@ +import { useEffect, useState } from 'react' +import type { ZenTheme } from '../mods' + +type SortOrder = 'default' | 'asc' | 'desc' + +interface ModsSearchState { + search: string + createdSort: SortOrder + updatedSort: SortOrder + page: number + limit: number +} + +const DEFAULT_LIMIT = 12 + +export function useModsSearch(mods: ZenTheme[]) { + const [searchParams, setSearchParams] = useState() + const [state, setState] = useState({ + search: '', + createdSort: 'default', + updatedSort: 'default', + page: 1, + limit: DEFAULT_LIMIT, + }) + + // Initialize search params + useEffect(() => { + const params = new URLSearchParams(window.location.search) + setSearchParams(params) + setState({ + search: params.get('q') || '', + createdSort: (params.get('created') as SortOrder) || 'default', + updatedSort: (params.get('updated') as SortOrder) || 'default', + page: Number.parseInt(params.get('page') || '1', 10), + limit: Number.parseInt(params.get('limit') || String(DEFAULT_LIMIT), 10), + }) + }, []) + + // Update URL when state changes + useEffect(() => { + if (!searchParams) return + + if (state.search) { + searchParams.set('q', state.search) + } else { + searchParams.delete('q') + } + + if (state.createdSort !== 'default') { + searchParams.set('created', state.createdSort) + } else { + searchParams.delete('created') + } + + if (state.updatedSort !== 'default') { + searchParams.set('updated', state.updatedSort) + } else { + searchParams.delete('updated') + } + + if (state.page > 1) { + searchParams.set('page', state.page.toString()) + } else { + searchParams.delete('page') + } + + if (state.limit !== DEFAULT_LIMIT) { + searchParams.set('limit', state.limit.toString()) + } else { + searchParams.delete('limit') + } + + const newUrl = `${window.location.pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}` + window.history.replaceState({}, '', newUrl) + }, [state, searchParams]) + + const filteredMods = (() => { + let filtered = [...mods] + + // Filter by search + const searchTerm = state.search.toLowerCase() + if (searchTerm) { + filtered = filtered.filter( + (mod) => + mod.name.toLowerCase().includes(searchTerm) || + mod.description.toLowerCase().includes(searchTerm) || + mod.author.toLowerCase().includes(searchTerm) || + (mod.tags?.some((tag) => tag.toLowerCase().includes(searchTerm)) ?? + false), + ) + } + + // Sort by createdAt if chosen + if (state.createdSort !== 'default') { + filtered.sort((a, b) => { + const diff = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + return state.createdSort === 'asc' ? diff : -diff + }) + } + + // Sort by updatedAt if chosen + if (state.updatedSort !== 'default') { + filtered.sort((a, b) => { + const diff = + new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + return state.updatedSort === 'asc' ? diff : -diff + }) + } + + return filtered + })() + + // Calculate pagination + const totalPages = Math.ceil(filteredMods.length / state.limit) + const startIndex = (state.page - 1) * state.limit + const endIndex = startIndex + state.limit + const paginatedMods = filteredMods.slice(startIndex, endIndex) + + const setSearch = (search: string) => { + setState((prev) => ({ ...prev, search, page: 1 })) // Reset page when search changes + } + + const toggleCreatedSort = () => { + setState((prev) => ({ + ...prev, + createdSort: + prev.createdSort === 'default' + ? 'asc' + : prev.createdSort === 'asc' + ? 'desc' + : 'default', + page: 1, // Reset page when sort changes + })) + } + + const toggleUpdatedSort = () => { + setState((prev) => ({ + ...prev, + updatedSort: + prev.updatedSort === 'default' + ? 'asc' + : prev.updatedSort === 'asc' + ? 'desc' + : 'default', + page: 1, // Reset page when sort changes + })) + } + + const setPage = (page: number) => { + setState((prev) => ({ + ...prev, + page: Math.max(1, Math.min(page, totalPages)), + })) + } + + const setLimit = (limit: number) => { + setState((prev) => ({ ...prev, limit, page: 1 })) // Reset page when limit changes + } + + return { + search: state.search, + createdSort: state.createdSort, + updatedSort: state.updatedSort, + page: state.page, + limit: state.limit, + totalPages, + totalItems: filteredMods.length, + setSearch, + toggleCreatedSort, + toggleUpdatedSort, + setPage, + setLimit, + mods: paginatedMods, + searchParams, + } +}