303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
"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<VirtuosoHandle | null>;
|
|
}
|
|
|
|
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<number | null>(
|
|
() => 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<boolean>(
|
|
() => batchId != null && !!fadeController?.isTriggered(batchId),
|
|
);
|
|
const ref = useRef<HTMLDivElement | null>(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 (
|
|
<div
|
|
ref={ref}
|
|
data-masonry-row={rowIdx}
|
|
className={showFade ? "grid gap-5 pb-5 fade-in" : "grid gap-5 pb-5"}
|
|
style={{
|
|
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
|
...(hiddenUntilSeen ? { opacity: 0 } : null),
|
|
}}
|
|
>
|
|
{row.map((img) => (
|
|
<ImageCard key={img.id} image={img} view={view} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className="grid gap-5"
|
|
style={{ gridTemplateColumns: `repeat(${cssCols}, minmax(0, 1fr))` }}
|
|
>
|
|
{images.map((img) => (
|
|
<ImageCard key={img.id} image={img} view={view} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<number>(ssrAnchorPage);
|
|
const totalItemsRef = useRef<number>(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<HTMLElement>("[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 (
|
|
<>
|
|
<Virtuoso
|
|
ref={(h) => { 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 (
|
|
<MasonryRow
|
|
rowIdx={rowIdx}
|
|
row={row}
|
|
cols={cols}
|
|
view={view}
|
|
fadeController={fadeController}
|
|
fadeMs={fadeMs}
|
|
/>
|
|
);
|
|
}}
|
|
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 && (
|
|
<div className="flex items-center justify-center gap-2 py-4 text-xs font-mono text-[var(--color-fg-muted)]">
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading next page...
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|