import React, { useState, useEffect, useRef, useCallback, memo, } from 'react'; import ArtworkCard from './ArtworkCard'; import './MasonryGallery.css'; // ── Masonry helpers ──────────────────────────────────────────────────────── const ROW_SIZE = 8; const ROW_GAP = 16; function applyMasonry(grid) { if (!grid) return; Array.from(grid.querySelectorAll('.nova-card')).forEach((card) => { const media = card.querySelector('.nova-card-media') || card; let height = media.getBoundingClientRect().height || 200; // Clamp to the computed max-height so the span never over-reserves rows // when CSS max-height kicks in (e.g. portrait images capped to 2×16:9). const cssMaxH = parseFloat(getComputedStyle(media).maxHeight); if (!isNaN(cssMaxH) && cssMaxH > 0 && cssMaxH < height) { height = cssMaxH; } const span = Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_SIZE + ROW_GAP))); card.style.gridRowEnd = `span ${span}`; }); } function waitForImages(el) { return Promise.all( Array.from(el.querySelectorAll('img')).map((img) => img.decode ? img.decode().catch(() => null) : Promise.resolve(), ), ); } // ── Page fetch helpers ───────────────────────────────────────────────────── /** * Fetch the next page of data. * * The response is either: * - JSON { artworks: [...], next_cursor: '...' } when X-Requested-With is * sent and the controller returns JSON (future enhancement) * - HTML page – we parse [data-react-masonry-gallery] from it and read its * data-artworks / data-next-cursor / data-next-page-url attributes. */ async function fetchPageData(url) { const res = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const ct = res.headers.get('content-type') || ''; // JSON fast-path (if controller ever returns JSON) if (ct.includes('application/json')) { const json = await res.json(); return { artworks: json.artworks ?? [], nextCursor: json.next_cursor ?? null, nextPageUrl: json.next_page_url ?? null, }; } // HTML: parse and extract mount-container data attributes const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const el = doc.querySelector('[data-react-masonry-gallery]'); if (!el) return { artworks: [], nextCursor: null, nextPageUrl: null }; let artworks = []; try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ } return { artworks, nextCursor: el.dataset.nextCursor || null, nextPageUrl: el.dataset.nextPageUrl || null, }; } // ── Skeleton row ────────────────────────────────────────────────────────── function SkeletonCard() { return
; } // ── Ranking API helpers ─────────────────────────────────────────────────── /** * Map a single ArtworkListResource item (from /api/rank/*) to the internal * artwork object shape used by ArtworkCard. */ function mapRankApiArtwork(item) { const w = item.dimensions?.width ?? null; const h = item.dimensions?.height ?? null; const thumb = item.thumbnail_url ?? null; const webUrl = item.urls?.web ?? item.category?.url ?? null; return { id: item.id ?? null, name: item.title ?? item.name ?? null, thumb: thumb, thumb_url: thumb, uname: item.author?.name ?? '', username: item.author?.username ?? item.author?.name ?? '', avatar_url: item.author?.avatar_url ?? null, category_name: item.category?.name ?? '', category_slug: item.category?.slug ?? '', slug: item.slug ?? '', url: webUrl, width: w, height: h, }; } /** * Fetch ranked artworks from the ranking API. * Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure. */ async function fetchRankApiArtworks(endpoint, rankType) { try { const url = new URL(endpoint, window.location.href); if (rankType) url.searchParams.set('type', rankType); const res = await fetch(url.toString(), { credentials: 'same-origin', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, }); if (!res.ok) return { artworks: [] }; const json = await res.json(); const items = Array.isArray(json.data) ? json.data : []; return { artworks: items.map(mapRankApiArtwork) }; } catch { return { artworks: [] }; } } const SKELETON_COUNT = 10; // ── Main component ──────────────────────────────────────────────────────── /** * MasonryGallery * * Props (all optional – set via data attributes in entry-masonry-gallery.jsx): * artworks [] Initial artwork objects * galleryType string Maps to data-gallery-type (e.g. 'trending') * cursorEndpoint string|null Route for cursor-based feeds (e.g. For You) * initialNextCursor string|null First cursor token * initialNextPageUrl string|null First "next page" URL (page-based feeds) * limit number Items per page (default 40) * rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data * source when no SSR artworks are available * rankType string|null Ranking API ?type= param (trending|new_hot|best) */ function MasonryGallery({ artworks: initialArtworks = [], galleryType = 'discover', cursorEndpoint = null, initialNextCursor = null, initialNextPageUrl = null, limit = 40, rankApiEndpoint = null, rankType = null, }) { const [artworks, setArtworks] = useState(initialArtworks); const [nextCursor, setNextCursor] = useState(initialNextCursor); const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl); const [loading, setLoading] = useState(false); const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl); const gridRef = useRef(null); const triggerRef = useRef(null); // ── Ranking API fallback ─────────────────────────────────────────────── // When the server-side render provides no initial artworks (e.g. cache miss // or empty page result) and a ranking API endpoint is configured, perform a // client-side fetch from the ranking API to hydrate the grid. // Satisfies spec: "Fallback: Latest if ranking missing". useEffect(() => { if (initialArtworks.length > 0) return; // SSR artworks already present if (!rankApiEndpoint) return; // no API endpoint configured let cancelled = false; setLoading(true); fetchRankApiArtworks(rankApiEndpoint, rankType).then(({ artworks: ranked }) => { if (cancelled) return; if (ranked.length > 0) { setArtworks(ranked); setDone(true); // ranking API returns a full list; no further pagination } setLoading(false); }); return () => { cancelled = true; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // ── Masonry re-layout ────────────────────────────────────────────────── const relayout = useCallback(() => { const g = gridRef.current; if (!g) return; applyMasonry(g); waitForImages(g).then(() => applyMasonry(g)); }, []); // Re-layout whenever artworks list changes. // Defer by one requestAnimationFrame so the browser has resolved // aspect-ratio heights before we measure with getBoundingClientRect(). useEffect(() => { const raf = requestAnimationFrame(() => relayout()); return () => cancelAnimationFrame(raf); }, [artworks, relayout]); // Re-layout on container resize (column width changes) useEffect(() => { const g = gridRef.current; if (!g || !('ResizeObserver' in window)) return; const ro = new ResizeObserver(relayout); ro.observe(g); return () => ro.disconnect(); }, [relayout]); // ── Load more ────────────────────────────────────────────────────────── const fetchNext = useCallback(async () => { if (loading || done) return; // Build the URL to fetch let fetchUrl = null; if (cursorEndpoint && nextCursor) { const u = new URL(cursorEndpoint, window.location.href); u.searchParams.set('cursor', nextCursor); u.searchParams.set('limit', String(limit)); fetchUrl = u.toString(); } else if (nextPageUrl) { fetchUrl = nextPageUrl; } if (!fetchUrl) { setDone(true); return; } setLoading(true); try { const { artworks: newItems, nextCursor: nc, nextPageUrl: np } = await fetchPageData(fetchUrl); if (!newItems.length) { setDone(true); } else { setArtworks((prev) => [...prev, ...newItems]); if (cursorEndpoint) { setNextCursor(nc); if (!nc) setDone(true); } else { setNextPageUrl(np); if (!np) setDone(true); } } } catch { setDone(true); } finally { setLoading(false); } }, [loading, done, cursorEndpoint, nextCursor, nextPageUrl, limit]); // ── Intersection observer for infinite scroll ────────────────────────── useEffect(() => { if (done) return; const trigger = triggerRef.current; if (!trigger || !('IntersectionObserver' in window)) return; const io = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) fetchNext(); }, { rootMargin: '900px', threshold: 0 }, ); io.observe(trigger); return () => io.disconnect(); }, [done, fetchNext]); // Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages. // Discover feeds (home/discover page) retain the same 5-col layout. const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6'; // ── Render ───────────────────────────────────────────────────────────── return (All caught up
)} > ) : ( /* Empty state – gallery-type-specific messaging handled by caller */No artworks found for this section yet.