Files
pinkudex/components/grid/LibraryGrid.tsx
T
2026-05-26 22:46:00 +02:00

444 lines
19 KiB
TypeScript
Raw 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePathname, useSearchParams } from "next/navigation";
import type { VirtuosoHandle } from "react-virtuoso";
import { MasonryGrid } from "./MasonryGrid";
import { PaginationBar } from "./PaginationBar";
import { useInfiniteScrollEnabled } from "./InfiniteScrollToggle";
import { useSettings } from "@/components/settings/SettingsProvider";
import type { CardImage } from "./ImageCard";
import type { LibraryView } from "./ViewToggle";
interface Props {
initialItems: CardImage[];
initialPage: number;
totalPages: number;
totalCount: number;
view: LibraryView;
infiniteScrollEnabled?: boolean;
}
/**
* Wrapper that owns the "loaded pages" state for the cover grid. The
* top + bottom pagination bars and the grid itself all read from here
* so the bottom bar can show "Pages 17 of 7" once the user has
* scroll-appended through the whole result set.
*
* Top bar stays anchored at `initialPage` — that's where the user
* landed via URL. Bottom bar reflects `loadedEnd`, the highest page
* currently appended.
*/
export function LibraryGrid({
initialItems,
initialPage,
totalPages,
totalCount,
view,
infiniteScrollEnabled: infiniteFromProp = true,
}: Props) {
const sp = useSearchParams();
const pathname = usePathname();
// The toggle in the FilterBar persists per-user-tab via localStorage.
// The prop is the page-level "is this surface ever allowed to
// infinite-scroll?" gate; AND with the user's preference.
const userInfinite = useInfiniteScrollEnabled();
const infiniteScrollEnabled = infiniteFromProp && userInfinite;
const { settings } = useSettings();
const fadeMs = settings.fadeTransitions ? Math.max(0, settings.fadeDurationMs ?? 400) : 0;
// Loaded items split into two buckets: the SSR-rendered initial
// page (kept stable for hydration) and any appended-by-fetch pages.
const [extra, setExtra] = useState<CardImage[]>([]);
const [loadedEnd, setLoadedEnd] = useState<number>(initialPage);
// The page currently in the viewport, derived from Virtuoso's
// first-visible-row index. Used solely for the "Page X of Y" label
// — navigation still keys off `initialPage` (URL anchor) and
// `loadedEnd`. Defaults to initialPage so SSR matches.
const [visiblePage, setVisiblePage] = useState<number>(initialPage);
const pageSize = Math.max(25, Math.min(500, settings.coverPageSize || 100));
// Per-batch fade controller. Each appended page is a "batch"; when
// any row of that batch first intersects the viewport, every row in
// the same batch fades in together (rather than one-row-at-a-time
// as the user scrolls past). Rows subscribe to their batch's trigger
// so they all flip to "animated" at once.
const fadeController = useMemo(() => {
let seq = 0;
const itemBatch = new Map<number, number>();
const batchIds = new Map<number, number[]>();
const triggered = new Set<number>();
const subs = new Map<number, Set<() => void>>();
return {
addBatch(ids: number[]): number {
seq += 1;
const id = seq;
batchIds.set(id, ids);
for (const it of ids) itemBatch.set(it, id);
return id;
},
batchIdOf(itemId: number): number | null {
return itemBatch.get(itemId) ?? null;
},
isTriggered(batchId: number): boolean {
return triggered.has(batchId);
},
trigger(batchId: number) {
if (triggered.has(batchId)) return;
triggered.add(batchId);
const set = subs.get(batchId);
if (set) for (const cb of set) cb();
},
subscribe(batchId: number, cb: () => void): () => void {
let set = subs.get(batchId);
if (!set) { set = new Set(); subs.set(batchId, set); }
set.add(cb);
return () => { set?.delete(cb); };
},
// Drop a batch entirely — items no longer count as pending so a
// future remount won't replay the keyframe.
expire(batchId: number) {
const ids = batchIds.get(batchId);
if (ids) for (const it of ids) itemBatch.delete(it);
batchIds.delete(batchId);
triggered.delete(batchId);
subs.delete(batchId);
},
reset() {
seq = 0;
itemBatch.clear();
batchIds.clear();
triggered.clear();
subs.clear();
},
};
}, []);
const fetchInFlightRef = useRef<boolean>(false);
// Page number currently being fetched (or just resolved). Combined
// with fetchInFlightRef it dedupes simultaneous requests for the same
// page — strict-mode double-invoke and Virtuoso's onEndReached firing
// twice in rapid succession both hit this.
const lastFetchTargetRef = useRef<number>(0);
// Auto-fetch suppression: after appendNextPage resolves we set this
// true. Virtuoso's onEndReached path checks it and bails. The user
// scrolling at least one full viewport flips it back to false so the
// next bottom-trigger can append. Without this, mounting on a page
// whose SSR rows are shorter than the viewport causes onEndReached
// to fire repeatedly, chaining 3-4 page appends instantly and
// dragging visiblePage way past the URL anchor.
const autoFetchPausedRef = useRef<boolean>(false);
const [isFetching, setIsFetching] = useState(false);
// Reset when the SSR-anchor changes (filter/sort/page nav). Page
// remount via key in app/page.tsx already handles most of this.
useEffect(() => {
setExtra([]);
setLoadedEnd(initialPage);
fadeController.reset();
}, [initialPage, initialItems, fadeController]);
const allItems = useMemo(() => [...initialItems, ...extra], [initialItems, extra]);
// Mirror loadedEnd into a ref so the save closure (registered once
// on mount) always reads the current value, without rebinding.
const loadedEndRef = useRef(loadedEnd);
useEffect(() => { loadedEndRef.current = loadedEnd; }, [loadedEnd]);
// Reflect the visible page in the URL with `replaceState` (no
// history push, so the back button still goes to the previous
// route, not through every scroll position). Debounced via
// visiblePage state which only updates when the bottom-most-visible
// row changes pages — already throttled at the source.
useEffect(() => {
if (typeof window === "undefined") return;
const usp = new URLSearchParams(sp.toString());
if (visiblePage > 1) usp.set("page", String(visiblePage));
else usp.delete("page");
const qs = usp.toString();
const target = qs ? `${pathname}?${qs}` : pathname;
if (window.location.pathname + window.location.search !== target) {
window.history.replaceState(window.history.state, "", target);
}
}, [visiblePage, sp, pathname]);
// Track when we're mounted on the client so the portal can target
// document.body without breaking SSR.
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
// Imperative handle on Virtuoso so we can call scrollToIndex even
// when the target row has been unmounted from DOM (it's outside the
// overscan window).
const virtuosoHandleRef = useRef<VirtuosoHandle | null>(null);
// Scroll + appended-page restoration. The page is `force-dynamic` so
// Next.js re-renders fresh on back-nav (Router Cache doesn't apply),
// which means scroll is *not* preserved automatically.
//
// Strategy: save scrollY + loadedEnd to sessionStorage on every
// scroll (debounced) and on cleanup. On mount, if a snapshot exists,
// re-fetch any pages the user had appended, then scrollTo with a
// retry loop because Virtuoso lazily measures rows and the page may
// briefly be too short for the saved scrollY to be valid.
useEffect(() => {
if (typeof window === "undefined") return;
const key = `pinkudex:scroll:${pathname}?${sp.toString()}`;
// Skip restore when arriving via an internal Prev/Next click
// (PaginationBar sets this marker before pushing). Otherwise the
// user clicking Prev all the way back to / would replay a snapshot
// saved during their previous scroll session, re-fetching pages
// 2N and re-creating the visiblePage drift loop.
const internalMarker = sessionStorage.getItem("pinkudex:nav-internal");
if (internalMarker) {
sessionStorage.removeItem("pinkudex:nav-internal");
// Also clear the snapshot for this URL so subsequent scrolls
// capture fresh state instead of compounding on the old one.
try { sessionStorage.removeItem(key); } catch { /* ignore */ }
return;
}
let cancelled = false;
const restore = async () => {
let snap: { scrollY: number; loadedEnd: number } | null = null;
try {
const raw = sessionStorage.getItem(key);
if (raw) snap = JSON.parse(raw);
} catch { /* corrupt — ignore */ }
if (!snap || snap.scrollY <= 0) return;
// Refetch missing appended pages so the document is tall enough
// for the saved scrollY to land somewhere meaningful.
if (snap.loadedEnd > initialPage && infiniteScrollEnabled) {
const collected: CardImage[] = [];
for (let p = initialPage + 1; p <= snap.loadedEnd && p <= totalPages; p++) {
if (cancelled) return;
const usp = new URLSearchParams(sp.toString());
usp.set("page", String(p));
try {
const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
if (!r.ok) break;
const data = (await r.json()) as { items: CardImage[]; page: number };
if (!Array.isArray(data.items)) break;
collected.push(...data.items);
} catch { break; }
}
if (cancelled) return;
if (collected.length > 0) {
setExtra(collected);
setLoadedEnd(snap.loadedEnd);
}
}
// Retry scrollTo for up to ~1s. Stops once position settles
// within a couple of pixels of target. Necessary because Next.js
// and Virtuoso both touch scroll/layout shortly after mount.
const target = snap.scrollY;
let attempts = 0;
const maxAttempts = 60;
const tryScroll = () => {
if (cancelled) return;
window.scrollTo(0, target);
attempts += 1;
if (Math.abs(window.scrollY - target) <= 2 || attempts >= maxAttempts) return;
requestAnimationFrame(tryScroll);
};
requestAnimationFrame(tryScroll);
};
restore();
// Save scroll + loadedEnd. No restoredRef gate — we want every
// scroll captured, and re-saving a stale value before restore is
// harmless (restore reads once, before it loops).
let t: ReturnType<typeof setTimeout> | null = null;
const save = () => {
try {
const payload = JSON.stringify({ scrollY: window.scrollY, loadedEnd: loadedEndRef.current });
sessionStorage.setItem(key, payload);
} catch { /* quota / private mode */ }
};
const onScroll = () => {
if (t) clearTimeout(t);
t = setTimeout(save, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("pagehide", save);
return () => {
cancelled = true;
// Intentionally NOT calling save() here. By the time cleanup
// runs on back-nav, Next.js has already reset window.scrollY=0,
// and saving that would clobber the snapshot.
window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", save);
if (t) clearTimeout(t);
};
// Run once per LibraryGrid mount. Filter changes already remount
// via the page-level key in app/page.tsx.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Scroll to the first row of `targetPage` once it's in the buffer.
// Pure DOM operation; caller is responsible for ensuring the target
// page has been loaded.
const scrollToLoadedPage = useCallback((targetPage: number): boolean => {
const cols = view === "portrait"
? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
: Math.max(2, Math.min(4, settings.gridColumns || 3));
const itemIdx = (targetPage - initialPage) * pageSize;
const rowIdx = Math.floor(itemIdx / cols);
const handle = virtuosoHandleRef.current;
if (!handle) return false;
handle.scrollToIndex({ index: rowIdx, align: "start", behavior: "smooth" });
return true;
}, [initialPage, pageSize, view, settings.gridColumns, settings.gridColumnsPortrait]);
// Append the page just past loadedEnd. Returns true on success, false
// if there's nothing more to load or the request fails. Shared between
// the infinite-scroll auto-fetch path, the explicit Load-More button,
// and the scroll-mode prefetch loop in scrollToPage.
const appendNextPage = useCallback(async (): Promise<boolean> => {
if (loadedEnd >= totalPages) return false;
const next = loadedEnd + 1;
// Dedupe: a second invocation for the same target while the first
// is in flight is a no-op. This catches strict-mode double-invoke
// in dev and Virtuoso firing onEndReached twice for one bottom hit.
if (fetchInFlightRef.current && lastFetchTargetRef.current === next) {
return false;
}
if (fetchInFlightRef.current) return false;
fetchInFlightRef.current = true;
lastFetchTargetRef.current = next;
setIsFetching(true);
try {
const usp = new URLSearchParams(sp.toString());
usp.set("page", String(next));
const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
if (!r.ok) return false;
const data = (await r.json()) as { items: CardImage[]; page: number; hasMore: boolean };
if (!Array.isArray(data.items) || data.items.length === 0) return false;
if (fadeMs > 0) {
fadeController.addBatch(data.items.map((it) => it.id));
}
setExtra((cur) => [...cur, ...data.items]);
setLoadedEnd(data.page);
return true;
} catch {
return false;
} finally {
fetchInFlightRef.current = false;
setIsFetching(false);
}
}, [loadedEnd, sp, totalPages, fadeMs, fadeController]);
// Auto-fetch path used by Virtuoso's onEndReached. Gated on the
// infinite-scroll preference. After each successful append we pause
// until the user scrolls — that breaks the chain where a freshly
// mounted SSR page has the bottom row near the viewport, causing
// onEndReached to fire repeatedly and append 3-4 pages back-to-back.
// The explicit Load-More / prefetch paths bypass this guard via
// appendNextPage directly.
const fetchNextPage = useCallback(async () => {
if (!infiniteScrollEnabled) return;
if (autoFetchPausedRef.current) return;
const ok = await appendNextPage();
if (ok) autoFetchPausedRef.current = true;
}, [infiniteScrollEnabled, appendNextPage]);
// Release the auto-fetch pause only on a real user gesture — wheel,
// touchmove, or a scroll-direction key. The earlier window-scroll
// listener was too sensitive: programmatic scroll-restoration and
// browser overflow-anchor adjustments fire scroll events and were
// bypassing the pause, which let the auto-fetch chain still run
// 3-4 pages deep on initial mount and after URL nav back to /.
useEffect(() => {
if (typeof window === "undefined") return;
const release = () => { autoFetchPausedRef.current = false; };
const onKey = (e: KeyboardEvent) => {
if (e.key === "PageDown" || e.key === "ArrowDown" || e.key === "End" || e.key === " " || e.key === "Spacebar") {
autoFetchPausedRef.current = false;
}
};
window.addEventListener("wheel", release, { passive: true });
window.addEventListener("touchmove", release, { passive: true });
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("wheel", release);
window.removeEventListener("touchmove", release);
window.removeEventListener("keydown", onKey);
};
}, []);
// Build the scroll-mode entry point. Backward across the SSR anchor
// returns false → URL nav. Forward past loadedEnd prefetches one
// page at a time until the target lands inside the buffer, then
// scrolls to it. Each loop iteration awaits a real network round-trip
// — this is the "always scroll, prefetch when needed" behavior.
const scrollToPageScrollMode = useCallback(async (targetPage: number): Promise<boolean> => {
if (targetPage < initialPage) return false;
if (targetPage > totalPages) return false;
while (targetPage > loadedEndRef.current) {
const ok = await appendNextPage();
if (!ok) return false;
}
return scrollToLoadedPage(targetPage);
}, [initialPage, totalPages, appendNextPage, scrollToLoadedPage]);
// Pick the right Prev/Next handler based on the user's preference.
// - "url" → no callback; bar always URL-navs.
// - "scroll" → prefetch + smooth scroll, URL fallback only on backward.
const onScrollToPageProp = settings.paginationMode === "scroll" ? scrollToPageScrollMode : undefined;
// Same-URL nav handler — fires when the bar would push to the page
// we're already at (e.g. clicking Prev at "Page 5 (scrolled from
// URL=/)" wants to land on page 1). Resets the appended buffer +
// scrolls to top so the user sees a real change instead of nothing.
const handleSamePageNav = useCallback(() => {
setExtra([]);
setLoadedEnd(initialPage);
fadeController.reset();
autoFetchPausedRef.current = true;
if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
}, [initialPage, fadeController]);
return (
<>
<MasonryGrid
images={allItems}
view={view}
infiniteScrollEnabled={infiniteScrollEnabled}
loadedEnd={loadedEnd}
totalPages={totalPages}
onEndReached={fetchNextPage}
isFetching={isFetching}
fadeController={fadeController}
fadeMs={fadeMs}
pageSize={pageSize}
ssrAnchorPage={initialPage}
onVisiblePageChange={setVisiblePage}
virtuosoHandleRef={virtuosoHandleRef}
/>
{/* Floating bar — portaled to <body> to escape any ancestor's
backdrop-filter / transform, which would otherwise trap a
`position: fixed` child to that ancestor's box. */}
{mounted && createPortal(
<div className="fixed inset-x-0 bottom-[12px] z-30 flex justify-center pointer-events-none">
<div
className="pointer-events-auto rounded-2xl shadow-2xl px-4 py-2.5 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 85%, transparent)" }}
>
<PaginationBar
currentPage={visiblePage}
totalPages={totalPages}
totalCount={totalCount}
onScrollToPage={onScrollToPageProp}
onSamePageNav={handleSamePageNav}
/>
</div>
</div>,
document.body,
)}
</>
);
}