207 lines
6.2 KiB
TypeScript
207 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationEllipsis,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from "@/components/ui/pagination";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export const ADMIN_LIST_PER_PAGE_OPTIONS = [10, 20, 25, 50, 100] as const;
|
|
|
|
/** Server-side pagination page slices with ellipsis markers. */
|
|
export function adminListPageSlices(
|
|
current: number,
|
|
last: number,
|
|
delta = 1,
|
|
): (number | "ellipsis")[] {
|
|
if (last <= 1) {
|
|
return last === 1 ? [1] : [];
|
|
}
|
|
const cap = delta * 2 + 3;
|
|
if (last <= cap) {
|
|
return Array.from({ length: last }, (_, i) => i + 1);
|
|
}
|
|
const set = new Set<number>([1, last]);
|
|
for (let p = current - delta; p <= current + delta; p++) {
|
|
if (p >= 1 && p <= last) {
|
|
set.add(p);
|
|
}
|
|
}
|
|
const sorted = [...set].sort((a, b) => a - b);
|
|
const out: (number | "ellipsis")[] = [];
|
|
let prev = 0;
|
|
for (const p of sorted) {
|
|
if (prev && p - prev > 1) {
|
|
out.push("ellipsis");
|
|
}
|
|
out.push(p);
|
|
prev = p;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function AdminPerPagePicker({
|
|
selectId,
|
|
perPage,
|
|
onChange,
|
|
}: {
|
|
/** Form control id used to distinguish multiple lists on one page. */
|
|
selectId: string;
|
|
perPage: number;
|
|
onChange: (next: number) => void;
|
|
}) {
|
|
const { t } = useTranslation(["common"]);
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Label htmlFor={selectId} className="text-sm leading-none whitespace-nowrap">
|
|
{t("pagination.perPage", { defaultValue: "Per page" })}
|
|
</Label>
|
|
<Select
|
|
modal={false}
|
|
value={String(perPage)}
|
|
onValueChange={(v) => {
|
|
if (v == null || v === "") {
|
|
return;
|
|
}
|
|
onChange(Number(v));
|
|
}}
|
|
>
|
|
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
|
|
<SelectValue placeholder={t("pagination.selectPlaceholder", { defaultValue: "Select" })} />
|
|
</SelectTrigger>
|
|
<SelectContent align="start" sideOffset={6}>
|
|
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
|
|
<SelectItem key={n} value={String(n)}>
|
|
{n}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Table footer: left summary, right per-page picker and pagination. */
|
|
export function AdminListPaginationFooter({
|
|
selectId,
|
|
total,
|
|
page,
|
|
lastPage,
|
|
perPage,
|
|
loading,
|
|
onPerPageChange,
|
|
onPageChange,
|
|
}: {
|
|
selectId: string;
|
|
total: number;
|
|
page: number;
|
|
lastPage: number;
|
|
perPage: number;
|
|
loading: boolean;
|
|
onPerPageChange: (nextPerPage: number) => void;
|
|
onPageChange: (nextPage: number) => void;
|
|
}) {
|
|
const { t } = useTranslation(["common"]);
|
|
return (
|
|
<div className="flex flex-col gap-4 border-t border-border/70 bg-muted/10 px-1 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-sm tabular-nums text-muted-foreground">
|
|
{t("pagination.summary", {
|
|
total,
|
|
page,
|
|
lastPage,
|
|
defaultValue: "{{total}} total, page {{page}} / {{lastPage}}",
|
|
})}
|
|
</p>
|
|
<div className="flex w-full flex-col items-stretch gap-4 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end lg:gap-6">
|
|
<AdminPerPagePicker
|
|
selectId={selectId}
|
|
perPage={perPage}
|
|
onChange={(next) => {
|
|
onPerPageChange(next);
|
|
}}
|
|
/>
|
|
{lastPage > 1 ? (
|
|
<Pagination className="mx-0 flex w-full max-w-none justify-center sm:w-auto sm:justify-end md:justify-end">
|
|
<PaginationContent className="flex-wrap justify-center md:justify-end">
|
|
<PaginationItem>
|
|
<PaginationPrevious
|
|
href="#"
|
|
text={t("pagination.previous", { defaultValue: "Previous" })}
|
|
aria-disabled={page <= 1 || loading}
|
|
className={cn(
|
|
(page <= 1 || loading) && "pointer-events-none opacity-50",
|
|
)}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (page <= 1 || loading) {
|
|
return;
|
|
}
|
|
onPageChange(Math.max(1, page - 1));
|
|
}}
|
|
/>
|
|
</PaginationItem>
|
|
{adminListPageSlices(page, lastPage).map((item, idx) =>
|
|
item === "ellipsis" ? (
|
|
<PaginationItem key={`${selectId}-e-${idx}`}>
|
|
<PaginationEllipsis />
|
|
</PaginationItem>
|
|
) : (
|
|
<PaginationItem key={`${selectId}-${item}`}>
|
|
<PaginationLink
|
|
href="#"
|
|
size="sm"
|
|
isActive={item === page}
|
|
className="min-w-8 tabular-nums"
|
|
aria-disabled={loading}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (loading) {
|
|
return;
|
|
}
|
|
onPageChange(item);
|
|
}}
|
|
>
|
|
{item}
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
),
|
|
)}
|
|
<PaginationItem>
|
|
<PaginationNext
|
|
href="#"
|
|
text={t("pagination.next", { defaultValue: "Next" })}
|
|
aria-disabled={page >= lastPage || loading}
|
|
className={cn(
|
|
(page >= lastPage || loading) && "pointer-events-none opacity-50",
|
|
)}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (page >= lastPage || loading) {
|
|
return;
|
|
}
|
|
onPageChange(Math.min(lastPage, page + 1));
|
|
}}
|
|
/>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
</Pagination>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|