Files
SkinbaseNova/public/legacy/js/legacy-gallery-init.js
2026-02-22 17:09:34 +01:00

330 lines
12 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 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 = '<div class="nova-skeleton-media"></div><div class="nova-skeleton-body"><div class="nova-skeleton-line"></div><div class="nova-skeleton-line"></div><div class="nova-skeleton-pill"></div></div>';
}
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 <h>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();
}
})();