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