Initial commit
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user