// folio/Grid.jsx — asymmetric editorial grid with scroll reveal, // hover effects, and per-breakpoint span patterns. const FOLIO_PATTERNS = { // Each row sums to the cols count. CSS picks per breakpoint via custom props. asymmetric: { lg: [7, 5, 4, 4, 4, 5, 7, 4, 8, 4, 4, 4], // 12 cols md: [6, 3, 3, 4, 2, 3, 3, 2, 4, 6, 3, 3], // 6 cols sm: [2, 1, 1, 2, 1, 1, 2, 1, 1, 2, 1, 1], // 2 cols }, balanced: { lg: [6, 6, 4, 4, 4, 6, 6, 4, 4, 4, 6, 6], md: [3, 3, 2, 2, 2, 3, 3, 2, 2, 2, 3, 3], sm: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], }, mosaic: { lg: [8, 4, 4, 4, 4, 4, 4, 8, 6, 6, 4, 4], md: [4, 2, 2, 2, 2, 2, 2, 4, 3, 3, 2, 2], sm: [2, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1], }, }; function classes(...xs) { return xs.filter(Boolean).join(" "); } function FolioGrid({ images, layout, captionMode, density, dimOthers, showSerials, onOpen, expandedId, showAll, onReveal, revealLimit = 5 }) { const rootRef = React.useRef(null); // Scroll-reveal: tiles in viewport are shown immediately on mount; // tiles below the fold fade in as they scroll into view. React.useEffect(() => { const root = rootRef.current; if (!root) return; const tiles = Array.from(root.querySelectorAll(".folio-tile")); const revealVisible = () => { const vh = window.innerHeight || document.documentElement.clientHeight; tiles.forEach((el) => { if (el.hasAttribute("data-in")) return; const r = el.getBoundingClientRect(); if (r.top < vh - 40 && r.bottom > 0) el.setAttribute("data-in", ""); }); }; const raf = requestAnimationFrame(revealVisible); let io; if (typeof IntersectionObserver !== "undefined") { io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { e.target.setAttribute("data-in", ""); io.unobserve(e.target); } }); }, { rootMargin: "0px 0px -8% 0px", threshold: 0.05 }); tiles.forEach((el) => io.observe(el)); } // Failsafe: force all tiles visible after 600ms const failsafe = setTimeout(() => { tiles.forEach((el) => el.setAttribute("data-in", "")); }, 600); const onScroll = () => revealVisible(); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); return () => { cancelAnimationFrame(raf); clearTimeout(failsafe); if (io) io.disconnect(); window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); }; }, [images, layout]); // Reveal animation: when showAll becomes true, stagger-animate the beyond tiles // using direct DOM manipulation so the animation runs in the same frame the // tiles become visible (avoids the React ref-timing gap). const prevShowAll = React.useRef(false); React.useEffect(() => { if (showAll && !prevShowAll.current) { // Small rAF so the browser has painted the newly-visible tiles first requestAnimationFrame(() => { const root = rootRef.current; if (!root) return; const beyondTiles = Array.from(root.querySelectorAll(".folio-tile.beyond")); beyondTiles.forEach((el, i) => { el.style.animationDelay = `${i * 75}ms`; el.classList.add("just-revealed"); // Clean up after animation is done const duration = 900 + i * 75; setTimeout(() => { el.classList.remove("just-revealed"); el.style.animationDelay = ""; }, duration); }); }); } prevShowAll.current = showAll; }, [showAll]); const pattern = FOLIO_PATTERNS[layout] || FOLIO_PATTERNS.asymmetric; return (
{images.length === 0 && (
— nothing here yet —
)} {images.map((img, i) => { const lgSpan = pattern.lg[i % pattern.lg.length]; const mdSpan = pattern.md[i % pattern.md.length]; const smSpan = pattern.sm[i % pattern.sm.length]; const big = lgSpan >= 7; const isExpanding = expandedId === img.id; const isBeyond = i >= revealLimit; const tile = (
onOpen && onOpen(i)}> {String(i + 1).padStart(2, "0")}
{img.no} · {img.cat}
{img.title}
); // Insert the "reveal the rest" banner after the limit-th tile const showRevealHere = i === revealLimit - 1 && !showAll && images.length > revealLimit; if (showRevealHere) { return ( {tile}
); } return tile; })}
); } Object.assign(window, { FolioGrid, FOLIO_PATTERNS });