"use client"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { useEffect, useMemo, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { ImageCard, type CardImage } from "./ImageCard"; import type { LibraryView } from "./ViewToggle"; import { useSettings } from "@/components/settings/SettingsProvider"; /** * Windowed grid: renders only visible rows, mounting/unmounting cards * as the user scrolls. Backed by react-virtuoso's window-scroll mode * so the page itself scrolls (not an inner div). * * Items are bucketed into rows of `cols` cards each — Virtuoso treats * each row as one item and measures its height dynamically. This keeps * the masonry-style CSS grid (equal columns, variable card heights) * working without forcing fixed-aspect cards. * * Below a small threshold we render a plain CSS grid — virtualization * overhead isn't worth it for short lists, and the simpler DOM tree * cooperates better with browser-native fade-in / scroll animations. */ interface Props { images: CardImage[]; view?: LibraryView; /** Highest page number currently appended. Used to decide whether * to keep firing endReached. */ loadedEnd?: number; /** Total pages for the current filter set. */ totalPages?: number; /** When false, infinite-scroll appends are suppressed (filtered * views, user toggle, etc.). */ infiniteScrollEnabled?: boolean; /** Called when Virtuoso detects we're near the end and more pages * exist. Parent fetches and grows `images`. */ onEndReached?: () => void; /** True while the parent is fetching the next page — drives the * small "Loading next page..." footer beneath the grid. */ isFetching?: boolean; /** Per-batch fade controller. Each row looks up which batch its * items belong to; the first row of that batch to intersect the * viewport triggers the batch, fading every row of the batch in * unison. */ fadeController?: FadeController; /** Fade animation duration in ms; matches CSS `--fade-duration`. */ fadeMs?: number; /** Items per logical "page" — used to derive the page currently * scrolled into view from the first-visible row index. */ pageSize?: number; /** The page the SSR rendered from. Item index 0 corresponds to this * page, not page 1, when the user landed via ?page=N. */ ssrAnchorPage?: number; /** Fires whenever the viewport's leading row changes pages. */ onVisiblePageChange?: (page: number) => void; /** Receives Virtuoso's imperative handle so the parent can call * `scrollToIndex` on it (e.g. for Prev/Next navigation jumps). */ virtuosoHandleRef?: React.MutableRefObject; } interface FadeController { batchIdOf(itemId: number): number | null; isTriggered(batchId: number): boolean; trigger(batchId: number): void; subscribe(batchId: number, cb: () => void): () => void; expire(batchId: number): void; } function MasonryRow({ rowIdx, row, cols, view, fadeController, fadeMs, }: { rowIdx: number; row: CardImage[]; cols: number; view: LibraryView; fadeController?: FadeController; fadeMs?: number; }) { // Resolve this row's batch once on mount. If null, the row was part // of the SSR initial page (or its batch already expired) — no fade. const [batchId] = useState( () => fadeController?.batchIdOf(row[0]?.id ?? -1) ?? null, ); const isFresh = batchId != null; // `animated` flips when the batch's trigger fires. Initial value // honors a batch that was already triggered (e.g. row remounted via // Virtuoso unmount/remount mid-fade). const [animated, setAnimated] = useState( () => batchId != null && !!fadeController?.isTriggered(batchId), ); const ref = useRef(null); // Subscribe to batch trigger so every row flips at the same time. useEffect(() => { if (!isFresh || !fadeController || batchId == null) return; return fadeController.subscribe(batchId, () => setAnimated(true)); }, [isFresh, fadeController, batchId]); // First row to intersect triggers the batch. Virtuoso pre-renders // rows in its overscan window (well below the viewport), so without // this gate the animation would finish off-screen. useEffect(() => { if (!isFresh || animated || !fadeController || batchId == null) return; const el = ref.current; if (!el) return; const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) { fadeController.trigger(batchId); io.disconnect(); return; } } }); io.observe(el); return () => io.disconnect(); }, [isFresh, animated, fadeController, batchId]); // Once the animation has played, drop the batch so that any future // remount of these rows doesn't replay the keyframe. useEffect(() => { if (!animated || !fadeController || batchId == null) return; const t = setTimeout(() => fadeController.expire(batchId), (fadeMs ?? 0) + 50); return () => clearTimeout(t); }, [animated, fadeController, batchId, fadeMs]); const showFade = isFresh && animated; const hiddenUntilSeen = isFresh && !animated; return (
{row.map((img) => ( ))}
); } export function MasonryGrid({ images, view = "landscape", loadedEnd = 1, totalPages = 1, infiniteScrollEnabled = true, onEndReached, isFetching = false, fadeController, fadeMs, pageSize = 100, ssrAnchorPage = 1, onVisiblePageChange, virtuosoHandleRef, }: Props) { const { settings } = useSettings(); const cols = view === "portrait" ? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6)) : Math.max(2, Math.min(4, settings.gridColumns || 3)); // CSS var is used by the simple-grid path so the column count is // correct on first paint regardless of what `settings.gridColumns` // resolves to client-side. Virtuoso path uses the JS number because // it bucket-rows items + only renders post-hydration anyway. const cssCols = view === "portrait" ? "var(--grid-cols-portrait, 6)" : "var(--grid-cols, 3)"; const rows = useMemo(() => { const out: CardImage[][] = []; for (let i = 0; i < images.length; i += cols) { out.push(images.slice(i, i + cols)); } return out; }, [images, cols]); if (images.length === 0) return null; // Threshold for short lists: skip virtualization and render plain // grid. ImageCard's lazy-loaded thumbnails handle the bytes side. // Above the threshold we go through Virtuoso *from first render* — // initialItemCount lets SSR emit a few rows so the simple-grid → // virtuoso swap (and its flash) is gone. const SIMPLE_THRESHOLD = 24; if (images.length <= SIMPLE_THRESHOLD) { return (
{images.map((img) => ( ))}
); } const canLoadMore = infiniteScrollEnabled && loadedEnd < totalPages; // Track which "page" sits at the top of the viewport. We scan // rendered rows on each scroll tick and pick the first one whose // bottom edge is past the viewport top — that's the row currently // crossing/just-below the top. Virtuoso's own rangeChanged reports // the *rendered* range (includes overscan above viewport) so it // lags by a row or two. const lastReportedPage = useRef(ssrAnchorPage); const totalItemsRef = useRef(images.length); useEffect(() => { totalItemsRef.current = images.length; }, [images.length]); useEffect(() => { if (!onVisiblePageChange) return; const update = () => { // Find the *bottom-most* row currently visible — i.e. the row // furthest along that has any pixel in the viewport. Combined // with last-item page derivation, the label flips the moment // the first card of the next page peeks in from the bottom of // the viewport. const vh = window.innerHeight; const els = document.querySelectorAll("[data-masonry-row]"); let chosen: HTMLElement | null = null; for (const el of els) { const r = el.getBoundingClientRect(); if (r.top < vh && r.bottom > 0) chosen = el; // keep updating } if (!chosen && els.length > 0) chosen = els[0]; if (!chosen) return; const idx = Number(chosen.dataset.masonryRow ?? 0); // Last item of the bottom-most visible row drives the page // label. As soon as a row containing the first card of the // next page enters from the bottom, the label flips. Clamp to // the actual item count so a partially-filled trailing row // (e.g. only 1 of 3 slots populated) doesn't claim a page that // has no real content yet. const total = totalItemsRef.current; const itemIdx = Math.min( idx * cols + Math.max(0, cols - 1), Math.max(0, total - 1), ); const page = ssrAnchorPage + Math.floor(itemIdx / pageSize); if (page !== lastReportedPage.current) { lastReportedPage.current = page; onVisiblePageChange(page); } }; let raf: number | null = null; const onScroll = () => { if (raf != null) return; raf = requestAnimationFrame(() => { raf = null; update(); }); }; window.addEventListener("scroll", onScroll, { passive: true }); update(); return () => { window.removeEventListener("scroll", onScroll); if (raf != null) cancelAnimationFrame(raf); }; }, [cols, pageSize, ssrAnchorPage, onVisiblePageChange]); return ( <> { if (virtuosoHandleRef) virtuosoHandleRef.current = h; }} useWindowScroll // `data` makes Virtuoso re-render rows whenever the array // reference changes (e.g. on append). Without it, item // closures freeze on the first render and updates to // fadeFromIndex / loadedEnd never propagate into rows. data={rows} initialItemCount={Math.min(rows.length, 8)} endReached={canLoadMore ? onEndReached : undefined} increaseViewportBy={600} itemContent={(rowIdx, row) => { if (!row) return null; return ( ); }} overscan={600} // Key by the first card's id, falling back to rowIdx for empty // rows. Joining every id in the row meant a partial trailing // row (e.g. 2 of 3 columns filled) re-keyed to a brand-new // identity once infinite-scroll filled the missing slots, // unmounting the row mid-scroll and blanking the in-flight // fade-in batch. The first id is stable across that fill. computeItemKey={(rowIdx, row) => (row && row[0] ? row[0].id : rowIdx)} /> {isFetching && (
Loading next page...
)} ); }