Files
2026-05-26 22:46:00 +02:00

163 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { ChevronLeft, ChevronRight, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
currentPage: number;
totalPages: number;
totalCount: number;
/** When provided, Prev/Next first try to scroll within the already-
* loaded grid (or prefetch + scroll, depending on mode). If the
* callback returns false (or resolves to false), the bar falls back
* to a URL navigation. */
onScrollToPage?: (targetPage: number) => boolean | Promise<boolean>;
/** Called when the user clicks Prev/Next but the target equals the
* current URL page (i.e. no router.push will happen). The grid uses
* this to clear its appended buffer + scroll to top, so a Prev click
* at "Page 5 (scrolled from URL=/)" snaps back to Page 1 instead of
* silently doing nothing. */
onSamePageNav?: () => void;
}
/**
* Bottom pagination bar. Preserves all existing query params (filters,
* sort, view) when navigating between pages — only the `page` key
* changes. `page=1` is dropped from the URL for a clean default.
*/
export function PaginationBar({ currentPage, totalPages, totalCount, onScrollToPage, onSamePageNav }: Props) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [, start] = useTransition();
const [jump, setJump] = useState<string>("");
// The page anchored in the URL — independent of the visible-page label
// (which drifts as the user scrolls into appended pages). Prev/Next nav
// math reads from here so a click at "Page 4 (showing)" while URL=?page=2
// walks back from 2, not from 4. Without this split, a buffer of
// appended pages combined with onEndReached chaining makes Prev appear
// to "stick" at page 1: each Prev pushes URL=/, the auto-fetch chain
// re-drifts visiblePage forward, and the loop never escapes.
const urlPageRaw = Number(sp.get("page") ?? "1");
const urlPage = Number.isFinite(urlPageRaw) && urlPageRaw >= 1
? Math.min(Math.floor(urlPageRaw), totalPages)
: 1;
const urlNav = (page: number) => {
const next = new URLSearchParams(sp.toString());
if (page > 1) next.set("page", String(page));
else next.delete("page");
const qs = next.toString();
// If the URL would not actually change (target page === urlPage),
// a router.push is a no-op visually and the loop "click Prev → no
// remount → drift returns" reappears. Hand off to onSamePageNav so
// the grid can reset its appended buffer + scroll to top.
if (page === urlPage && onSamePageNav) {
onSamePageNav();
return;
}
// Marker so the destination grid can distinguish an internal
// Prev/Next click from a browser back/forward. Internal nav skips
// scroll-restore (which otherwise replays a stale buffer snapshot
// and re-creates the visiblePage drift).
try { sessionStorage.setItem("pinkudex:nav-internal", "1"); } catch { /* ignore */ }
start(() => {
router.push(qs ? `${pathname}?${qs}` : pathname, { scroll: true });
});
};
const navTo = async (page: number) => {
if (onScrollToPage) {
const maybe = onScrollToPage(page);
const ok = typeof maybe === "boolean" ? maybe : await maybe;
if (ok) return;
}
urlNav(page);
};
const goJump = (e: React.FormEvent) => {
e.preventDefault();
const target = Number(jump);
if (!Number.isFinite(target) || target < 1) return;
const clamped = Math.min(Math.max(1, Math.floor(target)), totalPages);
setJump("");
void navTo(clamped);
};
// Prev/Next button math — relative to the displayed (visible) page so
// clicks feel responsive to where the user is scrolled. The same-URL
// case is handled by onSamePageNav (buffer reset), which prevents the
// old "Prev does nothing" trap when target === urlPage.
const prevTarget = Math.max(1, currentPage - 1);
const nextTarget = Math.min(totalPages, currentPage + 1);
const canPrev = currentPage > 1;
const canNext = currentPage < totalPages;
const showJump = totalPages > 5;
return (
<div className="flex items-center justify-center gap-2 flex-wrap">
<button
type="button"
disabled={!canPrev}
onClick={() => void navTo(prevTarget)}
className={cn(
"inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass",
!canPrev
? "opacity-40 cursor-not-allowed"
: "glass-hover cursor-pointer",
)}
>
<ChevronLeft className="w-4 h-4" />
Prev
</button>
<div className="text-sm font-mono text-[var(--color-fg-dim)] px-2 text-center tabular-nums min-w-[240px]">
Page <span className="text-[var(--color-cyan)]">{currentPage}</span> of {totalPages}
<span className="opacity-50"> · </span>
{totalCount.toLocaleString()} total
</div>
<button
type="button"
disabled={!canNext}
onClick={() => void navTo(nextTarget)}
className={cn(
"inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass",
!canNext
? "opacity-40 cursor-not-allowed"
: "glass-hover cursor-pointer",
)}
>
Next
<ChevronRight className="w-4 h-4" />
</button>
{showJump && (
<form onSubmit={goJump} className="ml-3 inline-flex items-center gap-1.5">
<span className="text-xs uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Jump to
</span>
<input
type="text"
inputMode="numeric"
value={jump}
onChange={(e) => setJump(e.target.value.replace(/[^0-9]/g, ""))}
placeholder={`1${totalPages}`}
className="w-20 glass rounded-md px-2 py-1 text-xs font-mono outline-none focus:border-[var(--color-cyan)] text-center"
/>
<button
type="submit"
disabled={!jump || Number(jump) < 1 || Number(jump) > totalPages}
className="inline-flex items-center justify-center w-7 h-7 rounded-md glass glass-hover disabled:opacity-40 cursor-pointer"
title="Jump"
>
<ArrowRight className="w-3.5 h-3.5" />
</button>
</form>
)}
</div>
);
}