nsfwapp/frontend/src/components/ui/Pagination.tsx
2026-04-27 14:40:16 +02:00

187 lines
5.5 KiB
TypeScript

// frontend\src\components\ui\Pagination.tsx
'use client'
import { type ReactNode } from 'react'
import clsx from 'clsx'
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronDownIcon,
} from '@heroicons/react/20/solid'
export type PaginationProps = {
page: number // 1-based
pageSize: number
totalItems: number
onPageChange: (page: number) => void
showSummary?: boolean
className?: string
ariaLabel?: string
prevLabel?: string
nextLabel?: string
firstLabel?: string
lastLabel?: string
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function PageButton({
disabled,
rounded,
onClick,
children,
title,
}: {
disabled?: boolean
rounded?: 'l' | 'r' | 'none'
onClick?: () => void
children: 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 h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0',
roundedCls,
disabled
? 'opacity-100 cursor-not-allowed bg-white text-gray-400 inset-ring inset-ring-gray-200 dark:bg-slate-900 dark:text-gray-500 dark:inset-ring-slate-800'
: 'cursor-pointer bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
)}
>
{children}
</button>
)
}
export default function Pagination({
page,
pageSize,
totalItems,
onPageChange,
showSummary = false,
className,
ariaLabel = 'Pagination',
prevLabel = 'Vorherige Seite',
nextLabel = 'Nächste Seite',
firstLabel = 'Erste Seite',
lastLabel = 'Letzte Seite',
}: 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 go = (p: number) => onPageChange(clamp(p, 1, totalPages))
return (
<div
className={clsx(
'flex items-center justify-center bg-transparent dark:border-white/10',
className
)}
>
<div className="flex flex-col items-center gap-2">
{showSummary ? (
<p className="text-sm text-gray-700 dark:text-gray-300 px-2">
Showing <span className="font-medium">{from}</span> to{' '}
<span className="font-medium">{to}</span> of{' '}
<span className="font-medium">{totalItems}</span> results
</p>
) : null}
<nav
aria-label={ariaLabel}
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
>
<PageButton
rounded="l"
onClick={() => go(1)}
disabled={current <= 1}
title={firstLabel}
>
<span className="sr-only">{firstLabel}</span>
<ChevronDoubleLeftIcon aria-hidden="true" className="size-5" />
</PageButton>
<PageButton
rounded="none"
onClick={() => go(current - 1)}
disabled={current <= 1}
title={prevLabel}
>
<span className="sr-only">{prevLabel}</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</PageButton>
<div className="relative">
<select
value={current}
onChange={(e) => go(Number(e.target.value))}
aria-label="Seite auswählen"
className={clsx(
'relative h-10 appearance-none bg-white pl-4 pr-10 text-sm font-semibold',
'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0',
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700',
'min-w-[112px] cursor-pointer'
)}
>
{Array.from({ length: totalPages }, (_, i) => {
const p = i + 1
return (
<option key={p} value={p} className="text-black dark:text-white">
{p} von {totalPages}
</option>
)
})}
</select>
<ChevronDownIcon
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-gray-500 dark:text-gray-400"
/>
</div>
<PageButton
rounded="none"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
title={nextLabel}
>
<span className="sr-only">{nextLabel}</span>
<ChevronRightIcon aria-hidden="true" className="size-5" />
</PageButton>
<PageButton
rounded="r"
onClick={() => go(totalPages)}
disabled={current >= totalPages}
title={lastLabel}
>
<span className="sr-only">{lastLabel}</span>
<ChevronDoubleRightIcon aria-hidden="true" className="size-5" />
</PageButton>
</nav>
</div>
</div>
)
}