163 lines
6.3 KiB
TypeScript
163 lines
6.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|