feat(pagination): add pagination and search param for mods page

This commit is contained in:
taroj1205 2025-01-31 08:54:24 +13:00
parent 262b8693a2
commit 0489d1b127
2 changed files with 297 additions and 55 deletions

View file

@ -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 type { ZenTheme } from '../mods'
import { library, icon } from '@fortawesome/fontawesome-svg-core' import { library, icon } from '@fortawesome/fontawesome-svg-core'
import { faSort, faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons' import { faSort, faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons'
import { useModsSearch } from '../hooks/useModsSearch'
// Add icons to the library // Add icons to the library
library.add(faSort, faSortUp, faSortDown) library.add(faSort, faSortUp, faSortDown)
@ -16,29 +18,29 @@ interface ModsListProps {
} }
export default function ModsList({ mods }: ModsListProps) { export default function ModsList({ mods }: ModsListProps) {
const [search, setSearch] = useState('') const {
const [createdSort, setCreatedSort] = useState<'default' | 'asc' | 'desc'>( search,
'default', createdSort,
) updatedSort,
const [updatedSort, setUpdatedSort] = useState<'default' | 'asc' | 'desc'>( page,
'default', limit,
) totalPages,
totalItems,
setSearch,
toggleCreatedSort,
toggleUpdatedSort,
setPage,
setLimit,
mods: paginatedMods,
searchParams,
} = useModsSearch(mods)
const toggleCreatedSort = () => { const [pageInput, setPageInput] = useState(page.toString())
setCreatedSort((prev) => {
if (prev === 'default') return 'asc'
if (prev === 'asc') return 'desc'
return 'default'
})
}
const toggleUpdatedSort = () => { // Keep page input in sync with actual page
setUpdatedSort((prev) => { useEffect(() => {
if (prev === 'default') return 'asc' setPageInput(page.toString())
if (prev === 'asc') return 'desc' }, [page])
return 'default'
})
}
function getSortIcon(state: 'default' | 'asc' | 'desc') { function getSortIcon(state: 'default' | 'asc' | 'desc') {
if (state === 'asc') return ascSortIcon if (state === 'asc') return ascSortIcon
@ -46,42 +48,85 @@ export default function ModsList({ mods }: ModsListProps) {
return defaultSortIcon return defaultSortIcon
} }
const filteredAndSortedMods = useMemo(() => { function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
let filtered = [...mods] setSearch(e.target.value)
}
// Filter by search function handleLimitChange(e: React.ChangeEvent<HTMLSelectElement>) {
const searchTerm = search.toLowerCase() setLimit(Number.parseInt(e.target.value, 10))
if (searchTerm) { }
filtered = filtered.filter(
(mod) => function handlePageSubmit(e: React.FormEvent) {
mod.name.toLowerCase().includes(searchTerm) || e.preventDefault()
mod.description.toLowerCase().includes(searchTerm) || const newPage = Number.parseInt(pageInput, 10)
mod.author.toLowerCase().includes(searchTerm) || if (!Number.isNaN(newPage) && newPage >= 1 && newPage <= totalPages) {
(mod.tags?.some((tag) => tag.toLowerCase().includes(searchTerm)) ?? setPage(newPage)
false), window.scrollTo(0, 0)
) } else {
setPageInput(page.toString())
}
}
function handlePageInputChange(e: React.ChangeEvent<HTMLInputElement>) {
setPageInput(e.target.value)
}
function getPageUrl(pageNum: number) {
let link = '/mods'
if (pageNum > 1) {
link += `?page=${pageNum}`
} }
// Sort by createdAt if chosen if (searchParams) {
if (createdSort !== 'default') { link += `&${searchParams.toString()}`
filtered.sort((a, b) => {
const diff =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
return createdSort === 'asc' ? diff : -diff
})
} }
// Sort by updatedAt if chosen return link
if (updatedSort !== 'default') { }
filtered.sort((a, b) => {
const diff =
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return updatedSort === 'asc' ? diff : -diff
})
}
return filtered function renderPagination() {
}, [mods, search, createdSort, updatedSort]) if (totalPages <= 1) return null
// Render page input for larger page counts
return (
<div className="mx-auto mb-12 flex items-center justify-center gap-4 px-8">
<a
href={getPageUrl(page - 1)}
className={`px-3 py-2 ${
page === 1
? 'pointer-events-none text-gray-400'
: 'text-dark hover:text-gray-600'
}`}
>
&lt;
</a>
<form onSubmit={handlePageSubmit} className="flex items-center gap-2">
<span className="text-sm">Page</span>
<input
type="text"
value={pageInput}
onChange={handlePageInputChange}
className="w-16 rounded border border-dark bg-transparent px-2 py-1 text-center text-sm"
aria-label="Page number"
/>
<span className="text-sm">
of {totalPages} ({totalItems} items)
</span>
</form>
<a
href={getPageUrl(page + 1)}
className={`px-3 py-2 ${
page === totalPages
? 'pointer-events-none text-gray-400'
: 'text-dark hover:text-gray-600'
}`}
>
&gt;
</a>
</div>
)
}
return ( return (
<div> <div>
@ -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" className="w-full rounded-full border-2 border-dark bg-transparent px-6 py-2 text-lg outline-none"
placeholder="Type to search..." placeholder="Type to search..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={handleSearch}
/> />
</div> </div>
<div className="flex flex-row gap-4"> <div className="flex flex-row items-center gap-4">
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<button <button
type="button" type="button"
@ -127,11 +172,28 @@ export default function ModsList({ mods }: ModsListProps) {
/> />
</button> </button>
</div> </div>
<div className="ml-auto flex items-center gap-2">
<label htmlFor="limit" className="text-sm font-semibold">
Items per page:
</label>
<select
id="limit"
value={limit}
onChange={handleLimitChange}
className="rounded border border-dark bg-transparent px-2 py-1 text-sm"
>
<option value="12">12</option>
<option value="24">24</option>
<option value="48">48</option>
<option value="96">96</option>
</select>
</div>
</div> </div>
</div> </div>
<div className="mx-auto grid grid-cols-1 gap-12 p-10 md:grid-cols-2 lg:grid-cols-3 lg:p-24 lg:px-24"> <div className="mx-auto grid grid-cols-1 gap-12 p-10 md:grid-cols-2 lg:grid-cols-3 lg:p-24 lg:px-24">
{filteredAndSortedMods.map((mod) => ( {paginatedMods.map((mod) => (
<a <a
key={mod.id} key={mod.id}
href={`/mods/${mod.id}`} href={`/mods/${mod.id}`}
@ -141,6 +203,7 @@ export default function ModsList({ mods }: ModsListProps) {
<img <img
src={mod.image} src={mod.image}
alt={mod.name} alt={mod.name}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-100 hover:scale-105" className="h-full w-full object-cover transition-transform duration-100 hover:scale-105"
/> />
</div> </div>
@ -156,6 +219,8 @@ export default function ModsList({ mods }: ModsListProps) {
</a> </a>
))} ))}
</div> </div>
{renderPagination()}
</div> </div>
) )
} }

177
src/hooks/useModsSearch.ts Normal file
View file

@ -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<URLSearchParams>()
const [state, setState] = useState<ModsSearchState>({
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,
}
}