"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; /** 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(""); // 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 (
Page {currentPage} of {totalPages} · {totalCount.toLocaleString()} total
{showJump && (
Jump to 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" />
)}
); }