nsfwapp/frontend/src/components/ui/Pagination.tsx
2025-12-26 01:25:04 +01:00

263 lines
7.9 KiB
TypeScript

'use client'
import * as React from 'react'
import clsx from 'clsx'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
type PageItem = number | 'ellipsis'
export type PaginationProps = {
page: number // 1-based
pageSize: number
totalItems: number
onPageChange: (page: number) => void
/** wie viele Seiten links/rechts neben aktueller Seite */
siblingCount?: number
/** wie viele Seiten am Anfang/Ende immer gezeigt werden */
boundaryCount?: number
/** Summary "Showing x to y of z" anzeigen */
showSummary?: boolean
/** Wrapper Klassen */
className?: string
/** Labels (optional) */
ariaLabel?: string
prevLabel?: string
nextLabel?: string
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function range(start: number, end: number): number[] {
const out: number[] = []
for (let i = start; i <= end; i++) out.push(i)
return out
}
function getPageItems(
totalPages: number,
current: number,
boundaryCount: number,
siblingCount: number
): PageItem[] {
if (totalPages <= 1) return [1]
const first = 1
const last = totalPages
const startPages = range(first, Math.min(boundaryCount, last))
const endPages = range(Math.max(last - boundaryCount + 1, boundaryCount + 1), last)
const siblingsStart = Math.max(
Math.min(
current - siblingCount,
last - boundaryCount - siblingCount * 2 - 1
),
boundaryCount + 1
)
const siblingsEnd = Math.min(
Math.max(
current + siblingCount,
boundaryCount + siblingCount * 2 + 2
),
last - boundaryCount
)
const items: PageItem[] = []
// start
items.push(...startPages)
// left gap
if (siblingsStart > boundaryCount + 1) {
items.push('ellipsis')
} else if (boundaryCount + 1 < last - boundaryCount) {
items.push(boundaryCount + 1)
}
// siblings
items.push(...range(siblingsStart, siblingsEnd))
// right gap
if (siblingsEnd < last - boundaryCount) {
items.push('ellipsis')
} else if (last - boundaryCount > boundaryCount) {
items.push(last - boundaryCount)
}
// end
items.push(...endPages)
// dedupe + keep order
const seen = new Set<string>()
return items.filter((x) => {
const k = String(x)
if (seen.has(k)) return false
seen.add(k)
return true
})
}
function PageButton({
active,
disabled,
rounded,
onClick,
children,
title,
}: {
active?: boolean
disabled?: boolean
rounded?: 'l' | 'r' | 'none'
onClick?: () => void
children: React.ReactNode
title?: string
}) {
const roundedCls =
rounded === 'l' ? 'rounded-l-md' : rounded === 'r' ? 'rounded-r-md' : ''
return (
<button
type="button"
disabled={disabled}
onClick={disabled ? undefined : onClick}
title={title}
className={clsx(
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
roundedCls,
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
active
? 'z-10 bg-indigo-600 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500'
: 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5'
)}
aria-current={active ? 'page' : undefined}
>
{children}
</button>
)
}
export default function Pagination({
page,
pageSize,
totalItems,
onPageChange,
siblingCount = 1,
boundaryCount = 1,
showSummary = true,
className,
ariaLabel = 'Pagination',
prevLabel = 'Previous',
nextLabel = 'Next',
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1)))
const current = clamp(page || 1, 1, totalPages)
if (totalPages <= 1) return null
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1
const to = Math.min(current * pageSize, totalItems)
const items = getPageItems(totalPages, current, boundaryCount, siblingCount)
const go = (p: number) => onPageChange(clamp(p, 1, totalPages))
return (
<div
className={clsx(
'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-transparent',
className
)}
>
{/* Mobile: nur Previous/Next */}
<div className="flex flex-1 justify-between sm:hidden">
<button
type="button"
onClick={() => go(current - 1)}
disabled={current <= 1}
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
>
{prevLabel}
</button>
<button
type="button"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
>
{nextLabel}
</button>
</div>
{/* Desktop: Summary + Zahlen */}
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
{showSummary ? (
<p className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{from}</span> to{' '}
<span className="font-medium">{to}</span> of{' '}
<span className="font-medium">{totalItems}</span> results
</p>
) : null}
</div>
<div>
<nav
aria-label={ariaLabel}
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
>
<button
type="button"
onClick={() => go(current - 1)}
disabled={current <= 1}
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
>
<span className="sr-only">{prevLabel}</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</button>
{items.map((it, idx) => {
if (it === 'ellipsis') {
return (
<span
key={`e-${idx}`}
className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 inset-ring inset-ring-gray-300 dark:text-gray-400 dark:inset-ring-gray-700"
>
</span>
)
}
return (
<PageButton
key={it}
active={it === current}
onClick={() => go(it)}
rounded="none"
>
{it}
</PageButton>
)
})}
<button
type="button"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
>
<span className="sr-only">{nextLabel}</span>
<ChevronRightIcon aria-hidden="true" className="size-5" />
</button>
</nav>
</div>
</div>
</div>
)
}