"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 1–7 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([]); const [loadedEnd, setLoadedEnd] = useState(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(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(); const batchIds = new Map(); const triggered = new Set(); const subs = new Map 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(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(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(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(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 // 2–N 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 | 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 => { 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 => { 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 ( <> {/* Floating bar — portaled to to escape any ancestor's backdrop-filter / transform, which would otherwise trap a `position: fixed` child to that ancestor's box. */} {mounted && createPortal(
, document.body, )} ); }