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
+443
View File
@@ -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 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,
)}
</>
);
}