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