// Nova gallery progressive enhancement: // - Masonry-like responsive layout (CSS grid row spans) // - Infinite scroll on top of server pagination (SEO-safe fallback) // - Skeleton placeholders while loading // - Virtualized rendering hints for long feeds (function () { 'use strict'; var GRID_V2_ENABLED = (function () { try { return new URLSearchParams(window.location.search).get('grid') === 'v2'; } catch (e) { return false; } })(); var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 200; var LOAD_TRIGGER_MARGIN = '900px'; var VIRTUAL_OBSERVER_MARGIN = '800px'; function toArray(list) { return Array.prototype.slice.call(list || []); } function queryNextPageUrl(root) { var pagination = root.querySelector('[data-gallery-pagination]'); if (!pagination) return null; var next = pagination.querySelector('a[rel="next"], a[aria-label="Next »"], a[aria-label="Next"], a[aria-label="pagination.next"]'); return next ? next.getAttribute('href') : null; } function buildCursorUrl(endpoint, cursor, limit) { if (!endpoint) return null; var url = new URL(endpoint, window.location.href); if (cursor) url.searchParams.set('cursor', cursor); if (limit) url.searchParams.set('limit', limit); return url.toString(); } function setSkeleton(root, active, count) { var box = root.querySelector('[data-gallery-skeleton]'); if (!box) return; box.innerHTML = ''; if (!active) { box.classList.remove('is-loading'); return; } var templateHost = root.querySelector('[data-gallery-skeleton-template]'); var templateNode = templateHost ? templateHost.firstElementChild : null; box.classList.add('is-loading'); var total = Math.max(4, count || 8); for (var i = 0; i < total; i += 1) { var sk = templateNode ? templateNode.cloneNode(true) : document.createElement('div'); if (!templateNode) { sk.className = 'nova-skeleton-card'; sk.innerHTML = '
'; } box.appendChild(sk); } } function waitForImages(container) { var imgs = toArray(container.querySelectorAll('img')); var promises = imgs.map(function (img) { try { if (img.decode) return img.decode().catch(function () { return null; }); } catch (e) { return null; } return null; }); return Promise.all(promises); } function applyMasonry(root) { var grid = root.querySelector('[data-gallery-grid]'); if (!grid) return; var rowSize = 8; var gap = 16; var cards = toArray(grid.querySelectorAll('.nova-card')); cards.forEach(function (card) { var media = card.querySelector('.nova-card-media') || card; var height = media.getBoundingClientRect().height || 200; // Lower the minimum forced span to avoid large empty space at the bottom. // Previously this used a very large minimum (18) which reserved too many rows. var minSpan = 1; var span = Math.max(minSpan, Math.ceil((height + gap) / (rowSize + gap))); card.style.gridRowEnd = 'span ' + span; }); } function activateBlurPreviews(root) { var imgs = toArray(root.querySelectorAll('img[data-blur-preview]')); imgs.forEach(function (img) { if (img.complete && img.naturalWidth > 0) { img.classList.add('is-loaded'); return; } img.addEventListener('load', function () { img.classList.add('is-loaded'); }, { once: true }); img.addEventListener('error', function () { img.classList.add('is-loaded'); }, { once: true }); }); } // Create an IntersectionObserver that applies content-visibility hints // to cards that leave the viewport. Using an observer avoids calling // getBoundingClientRect() in a scroll handler (which forces layout). // entry.boundingClientRect gives us the last rendered height without // triggering a synchronous layout recalculation. function makeVirtualObserver() { if (!('IntersectionObserver' in window)) return null; return new IntersectionObserver(function (entries) { entries.forEach(function (entry) { var card = entry.target; if (entry.isIntersecting) { card.style.contentVisibility = ''; card.style.containIntrinsicSize = ''; } else { // Capture the last-known rendered height before hiding. // 'auto px' keeps browser-managed width while reserving fixed height. var h = Math.max(160, Math.round(entry.boundingClientRect.height) || 220); card.style.contentVisibility = 'auto'; card.style.containIntrinsicSize = 'auto ' + h + 'px'; } }); }, { root: null, rootMargin: VIRTUAL_OBSERVER_MARGIN, threshold: 0 }); } function extractAndAppendCards(root, html) { var parser = new DOMParser(); var doc = parser.parseFromString(html, 'text/html'); var incomingGrid = doc.querySelector('[data-gallery-grid]'); if (!incomingGrid) return { appended: 0, nextUrl: null }; var targetGrid = root.querySelector('[data-gallery-grid]'); if (!targetGrid) return { appended: 0, nextUrl: null }; var cards = toArray(incomingGrid.querySelectorAll('.nova-card')); cards.forEach(function (card) { targetGrid.appendChild(card); }); var incomingPagination = doc.querySelector('[data-gallery-pagination]'); var currentPagination = root.querySelector('[data-gallery-pagination]'); if (incomingPagination && currentPagination) { currentPagination.innerHTML = incomingPagination.innerHTML; } return { appended: cards.length, nextUrl: queryNextPageUrl(root) }; } function initOne(root) { var grid = root.querySelector('[data-gallery-grid]'); if (!grid) return; root.classList.add('is-enhanced'); activateBlurPreviews(root); var state = { loading: false, nextUrl: queryNextPageUrl(root), cursorEndpoint: (root.dataset && root.dataset.galleryCursorEndpoint) || null, cursor: (root.dataset && root.dataset.galleryCursor) || null, limit: (root.dataset && parseInt(root.dataset.galleryLimit, 10)) || 40, done: false }; // virtualObserver is created lazily the first time card count exceeds // MAX_DOM_CARDS_FOR_VIRTUAL_HINT. Once active it watches every card. var virtualObserver = null; // Call after appending new cards. newCards is the array of freshly added // elements (pass [] on the initial render). When the threshold is first // crossed all existing cards are swept; thereafter only newCards are added. function checkVirtualization(newCards) { var allCards = toArray(grid.querySelectorAll('.nova-card')); if (allCards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) return; if (!virtualObserver) { // First time crossing the threshold — create observer and observe all. virtualObserver = makeVirtualObserver(); if (!virtualObserver) return; allCards.forEach(function (card) { virtualObserver.observe(card); }); return; } // Observer already running — just wire in newly appended cards. newCards.forEach(function (card) { virtualObserver.observe(card); }); } function relayout() { // Apply masonry synchronously first — the card already has inline aspect-ratio // set from image dimensions, so getBoundingClientRect() returns the correct // reserved height immediately at DOMContentLoaded without waiting for decode. // This collapses both the is-enhanced class change and span assignment into one // paint frame, eliminating the visible layout jump (CLS). if (!GRID_V2_ENABLED) { applyMasonry(root); } // Secondary pass after images finish decoding — corrects any lazy-loaded or // dynamically-appended cards whose heights weren't yet known. waitForImages(grid).then(function () { if (!GRID_V2_ENABLED) { applyMasonry(root); } // Virtualization check runs after the initial render too, in case the // page was loaded mid-scroll with many pre-rendered cards. checkVirtualization([]); }); } async function loadNextPage() { if (state.loading || state.done) return; var fetchUrl = (state.cursorEndpoint && state.cursor) ? buildCursorUrl(state.cursorEndpoint, state.cursor, state.limit) : state.nextUrl; if (!fetchUrl) return; state.loading = true; var sampleCards = toArray(grid.querySelectorAll('.nova-card')); var skeletonCount = Math.min(12, Math.max(4, sampleCards.length ? sampleCards.slice(-4).length * 2 : 8)); setSkeleton(root, true, skeletonCount); try { var response = await window.fetch(fetchUrl, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) throw new Error('Failed to load page'); var html = await response.text(); var result = extractAndAppendCards(root, html); state.nextUrl = result.nextUrl; if (!state.nextUrl || result.appended === 0) { state.done = true; } activateBlurPreviews(root); // Animate appended cards var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended); appendedCards.forEach(function (c) { c.classList.add('nova-card-enter'); // trigger reflow then add active class requestAnimationFrame(function () { c.classList.add('nova-card-enter-active'); }); c.addEventListener('transitionend', function te() { c.classList.remove('nova-card-enter', 'nova-card-enter-active'); c.removeEventListener('transitionend', te); }); }); relayout(); checkVirtualization(appendedCards); // After new cards appended, move the trigger to remain one-row-before-last. placeTrigger(); } catch (e) { state.done = true; } finally { state.loading = false; setSkeleton(root, false); } } var trigger = document.createElement('div'); trigger.setAttribute('aria-hidden', 'true'); trigger.className = 'h-px w-full'; function placeTrigger() { // Place the trigger inside the grid one row before the last row so // loading starts earlier (when the user reaches the penultimate row). var cards = toArray(grid.querySelectorAll('.nova-card')); var colCount = 1; try { var cols = window.getComputedStyle(grid).getPropertyValue('grid-template-columns'); if (cols) { var parts = cols.trim().split(/\s+/).filter(function (p) { return p.length > 0; }); colCount = Math.max(1, parts.length); } } catch (e) { colCount = 1; } if (cards.length === 0) { if (trigger.parentNode) trigger.parentNode.removeChild(trigger); grid.appendChild(trigger); return; } // Compute the first index of the last row, then step back one row to // place the trigger at the start of the penultimate row. var lastRowStart = Math.floor((cards.length - 1) / colCount) * colCount; var penultimateRowStart = Math.max(0, lastRowStart - colCount); var refIndex = penultimateRowStart; var ref = cards[refIndex] || null; if (trigger.parentNode) trigger.parentNode.removeChild(trigger); if (ref && ref.parentNode) { ref.parentNode.insertBefore(trigger, ref); } else { grid.appendChild(trigger); } } if ('IntersectionObserver' in window) { var io = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) loadNextPage(); }); }, { root: null, rootMargin: LOAD_TRIGGER_MARGIN, threshold: 0 }); // Place and observe the trigger. It will be moved after new cards are appended. placeTrigger(); io.observe(trigger); } window.addEventListener('resize', function () { relayout(); placeTrigger(); }, { passive: true }); relayout(); placeTrigger(); } function init() { toArray(document.querySelectorAll('[data-nova-gallery]')).forEach(initOne); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();