Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+162
View File
@@ -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>
);
}