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