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 { 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 {
search,
createdSort,
updatedSort,
page,
limit,
totalPages,
totalItems,
setSearch,
toggleCreatedSort,
toggleUpdatedSort,
setPage,
setLimit,
mods: paginatedMods,
searchParams,
} = useModsSearch(mods)
const toggleCreatedSort = () => {
setCreatedSort((prev) => {
if (prev === 'default') return 'asc'
if (prev === 'asc') return 'desc'
return 'default'
})
}
const [pageInput, setPageInput] = useState(page.toString())
const toggleUpdatedSort = () => {
setUpdatedSort((prev) => {
if (prev === 'default') return 'asc'
if (prev === 'asc') return 'desc'
return 'default'
})
}
// 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,43 +48,86 @@ export default function ModsList({ mods }: ModsListProps) {
return defaultSortIcon
}
const filteredAndSortedMods = useMemo(() => {
let filtered = [...mods]
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
setSearch(e.target.value)
}
// 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 handleLimitChange(e: React.ChangeEvent<HTMLSelectElement>) {
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<HTMLInputElement>) {
setPageInput(e.target.value)
}
function getPageUrl(pageNum: number) {
let link = '/mods'
if (pageNum > 1) {
link += `?page=${pageNum}`
}
if (searchParams) {
link += `&${searchParams.toString()}`
}
return link
}
function renderPagination() {
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>
)
}
// 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
})
}
// 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
})
}
return filtered
}, [mods, search, createdSort, updatedSort])
return (
<div>
<div className="mx-auto mb-8 flex flex-col items-start gap-4 px-8 lg:w-1/2">
@ -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}
/>
</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">
<button
type="button"
@ -127,11 +172,28 @@ export default function ModsList({ mods }: ModsListProps) {
/>
</button>
</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 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
key={mod.id}
href={`/mods/${mod.id}`}
@ -141,6 +203,7 @@ export default function ModsList({ mods }: ModsListProps) {
<img
src={mod.image}
alt={mod.name}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-100 hover:scale-105"
/>
</div>
@ -156,6 +219,8 @@ export default function ModsList({ mods }: ModsListProps) {
</a>
))}
</div>
{renderPagination()}
</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,
}
}