import React, { useState, useEffect, useRef, useCallback, memo, } from 'react'; import ArtworkGallery from '../artwork/ArtworkGallery'; import './MasonryGallery.css'; function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; } async function sendDiscoveryEvent(endpoint, payload) { if (!endpoint) return; try { await fetch(endpoint, { method: 'POST', credentials: 'same-origin', keepalive: true, headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify(payload), }); } catch { // Discovery telemetry should never block the gallery UX. } } // ── 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(); // Support multiple API payload shapes across endpoints. const artworks = Array.isArray(json.artworks) ? json.artworks : Array.isArray(json.data) ? json.data : Array.isArray(json.items) ? json.items : Array.isArray(json.results) ? json.results : []; const nextCursor = json.next_cursor ?? json.nextCursor ?? json.meta?.next_cursor ?? null; const nextPageUrl = json.next_page_url ?? json.nextPageUrl ?? json.meta?.next_page_url ?? null; const hasMore = typeof json.has_more === 'boolean' ? json.has_more : typeof json.hasMore === 'boolean' ? json.hasMore : null; return { artworks, nextCursor, nextPageUrl, hasMore, meta: json.meta ?? 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, hasMore: null, meta: 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, content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '', content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '', 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; function getMasonryCardProps(art, idx) { const title = (art.name || art.title || 'Untitled artwork').trim(); const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0; const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null; const categorySlug = (art.category_slug || '').toLowerCase(); const categoryName = (art.category_name || art.category || '').toLowerCase(); const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper']; const wideCategoryNames = ['photography', 'wallpapers']; const isWideEligible = aspectRatio !== null && aspectRatio > 2.0 && (wideCategories.includes(categorySlug) || wideCategoryNames.includes(categoryName)); return { articleClassName: `nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`, articleStyle: isWideEligible ? { gridColumn: 'span 2' } : undefined, frameClassName: 'rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 hover:ring-white/15 hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]', mediaClassName: 'nova-card-media relative w-full overflow-hidden bg-neutral-900', mediaStyle: hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : undefined, imageSrcSet: art.thumb_srcset || undefined, imageSizes: '(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw', imageWidth: hasDimensions ? art.width : undefined, imageHeight: hasDimensions ? art.height : undefined, loading: idx < 8 ? 'eager' : 'lazy', decoding: idx < 8 ? 'sync' : 'async', fetchPriority: idx === 0 ? 'high' : undefined, imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]', metricBadge: art.recommendation_reason ? { label: art.recommendation_reason, className: 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate', } : null, }; } // ── 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) * gridClassName string|null Optional CSS class override for grid columns/gaps */ function MasonryGallery({ artworks: initialArtworks = [], galleryType = 'discover', cursorEndpoint = null, initialNextCursor = null, initialNextPageUrl = null, limit = 40, rankApiEndpoint = null, rankType = null, gridClassName = null, discoveryEndpoint = null, algoVersion: initialAlgoVersion = 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 [algoVersion, setAlgoVersion] = useState(initialAlgoVersion); const gridRef = useRef(null); const triggerRef = useRef(null); const viewedArtworkIdsRef = useRef(new Set()); const clickedArtworkIdsRef = useRef(new Set()); // ── 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, hasMore, meta } = await fetchPageData(fetchUrl); if (meta?.algo_version) { setAlgoVersion(meta.algo_version); } if (!newItems.length) { setDone(true); } else { setArtworks((prev) => [...prev, ...newItems]); if (cursorEndpoint) { setNextCursor(nc); if (hasMore === false || !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]); useEffect(() => { if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined; const grid = gridRef.current; if (!grid || !(window.IntersectionObserver)) return undefined; const artworkIndex = new Map(artworks.map((art, index) => [String(art.id), { art, index }])); const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting || entry.intersectionRatio < 0.65) { return; } const card = entry.target.closest('[data-art-id]'); const artworkId = card?.getAttribute('data-art-id'); if (!artworkId || viewedArtworkIdsRef.current.has(artworkId)) { return; } const candidate = artworkIndex.get(artworkId); if (!candidate?.art?.id) { return; } viewedArtworkIdsRef.current.add(artworkId); observer.unobserve(entry.target); sendDiscoveryEvent(discoveryEndpoint, { event_type: 'view', artwork_id: Number(candidate.art.id), algo_version: candidate.art.recommendation_algo_version || algoVersion || undefined, meta: { gallery_type: galleryType, position: candidate.index + 1, source: candidate.art.recommendation_source || null, reason: candidate.art.recommendation_reason || null, score: candidate.art.recommendation_score ?? null, }, }); }); }, { threshold: [0.65] }, ); const cards = grid.querySelectorAll('[data-art-id]'); cards.forEach((card) => observer.observe(card)); return () => observer.disconnect(); }, [algoVersion, artworks, discoveryEndpoint, galleryType]); useEffect(() => { if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined; const grid = gridRef.current; if (!grid) return undefined; const handleClick = (event) => { const card = event.target.closest('[data-art-id]'); if (!card) return; const artworkId = card.getAttribute('data-art-id'); if (!artworkId || clickedArtworkIdsRef.current.has(artworkId)) { return; } const artwork = artworks.find((item) => String(item.id) === artworkId); if (!artwork?.id) { return; } clickedArtworkIdsRef.current.add(artworkId); sendDiscoveryEvent(discoveryEndpoint, { event_type: 'click', artwork_id: Number(artwork.id), algo_version: artwork.recommendation_algo_version || algoVersion || undefined, meta: { gallery_type: galleryType, source: artwork.recommendation_source || null, reason: artwork.recommendation_reason || null, score: artwork.recommendation_score ?? null, target_url: artwork.url || null, }, }); }; grid.addEventListener('click', handleClick, true); return () => grid.removeEventListener('click', handleClick, true); }, [algoVersion, artworks, discoveryEndpoint, galleryType]); // 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 = gridClassName || '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.