294 lines
9.6 KiB
JavaScript
294 lines
9.6 KiB
JavaScript
// 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 MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 220;
|
|
var LOAD_TRIGGER_MARGIN = '900px';
|
|
|
|
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 setSkeleton(root, active, count) {
|
|
var box = root.querySelector('[data-gallery-skeleton]');
|
|
if (!box) return;
|
|
box.innerHTML = '';
|
|
if (!active) {
|
|
box.classList.remove('is-loading');
|
|
return;
|
|
}
|
|
box.classList.add('is-loading');
|
|
var total = Math.max(4, count || 8);
|
|
for (var i = 0; i < total; i += 1) {
|
|
var sk = document.createElement('div');
|
|
sk.className = 'nova-skeleton-card';
|
|
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 applyVirtualizationHints(root) {
|
|
var grid = root.querySelector('[data-gallery-grid]');
|
|
if (!grid) return;
|
|
var cards = toArray(grid.querySelectorAll('.nova-card'));
|
|
if (cards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) {
|
|
cards.forEach(function (card) {
|
|
card.style.contentVisibility = '';
|
|
card.style.containIntrinsicSize = '';
|
|
});
|
|
return;
|
|
}
|
|
|
|
var viewportTop = window.scrollY;
|
|
var viewportBottom = viewportTop + window.innerHeight;
|
|
|
|
cards.forEach(function (card) {
|
|
var rect = card.getBoundingClientRect();
|
|
var top = rect.top + viewportTop;
|
|
var bottom = rect.bottom + viewportTop;
|
|
var farAbove = bottom < viewportTop - 1400;
|
|
var farBelow = top > viewportBottom + 2600;
|
|
|
|
if (farAbove || farBelow) {
|
|
var h = Math.max(160, rect.height || 220);
|
|
card.style.contentVisibility = 'auto';
|
|
card.style.containIntrinsicSize = Math.round(h) + 'px';
|
|
} else {
|
|
card.style.contentVisibility = '';
|
|
card.style.containIntrinsicSize = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
// loader overlay element (created lazily)
|
|
var loader = null;
|
|
function ensureLoader() {
|
|
if (loader) return loader;
|
|
loader = document.createElement('div');
|
|
loader.className = 'nova-loader-overlay';
|
|
var inner = document.createElement('div');
|
|
inner.className = 'nova-loader-spinner';
|
|
loader.appendChild(inner);
|
|
// place loader as child of root so it overlays grid area
|
|
loader.style.display = 'none';
|
|
root.style.position = root.style.position || '';
|
|
root.appendChild(loader);
|
|
return loader;
|
|
}
|
|
|
|
function showLoader() { var l = ensureLoader(); l.style.display = 'flex'; }
|
|
function hideLoader() { if (loader) loader.style.display = 'none'; }
|
|
|
|
root.classList.add('is-enhanced');
|
|
|
|
var state = {
|
|
loading: false,
|
|
nextUrl: queryNextPageUrl(root),
|
|
done: false
|
|
};
|
|
|
|
function relayout() {
|
|
waitForImages(grid).then(function () {
|
|
applyMasonry(root);
|
|
applyVirtualizationHints(root);
|
|
});
|
|
}
|
|
|
|
var rafId = null;
|
|
function onScrollOrResize() {
|
|
if (rafId) return;
|
|
rafId = window.requestAnimationFrame(function () {
|
|
rafId = null;
|
|
applyVirtualizationHints(root);
|
|
});
|
|
}
|
|
|
|
async function loadNextPage() {
|
|
if (state.loading || state.done || !state.nextUrl) return;
|
|
state.loading = true;
|
|
|
|
showLoader();
|
|
|
|
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(state.nextUrl, {
|
|
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;
|
|
}
|
|
|
|
// 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();
|
|
// 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);
|
|
hideLoader();
|
|
}
|
|
}
|
|
|
|
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();
|
|
onScrollOrResize();
|
|
placeTrigger();
|
|
}, { passive: true });
|
|
window.addEventListener('scroll', onScrollOrResize, { passive: true });
|
|
|
|
relayout();
|
|
placeTrigger();
|
|
}
|
|
|
|
function init() {
|
|
toArray(document.querySelectorAll('[data-nova-gallery]')).forEach(initOne);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|