// FeedGrid.js - Manages rendering of feed items in a grid
import { formatTimeAgo } from '../utils/formatters';
class FeedGrid {
constructor(container, options = {}) {
this.container = container;
this.options = {
showAuthor: true,
showDate: false,
isGallery: false,
imageLoadThreshold: 5, // Number of images to load immediately
lazyLoadOffset: '100px', // Load images 100px before they enter viewport
...options
};
this.galleryItems = [];
this.loadedItems = 0;
this.imageObserver = null;
this.galleryOpenHandler = null;
this.intersectionObserver = null;
this.resizeObserver = null;
// Element template cache for performance
this.templateCache = new Map();
this.initializeObservers();
}
/**
* Initialize intersection observer for lazy loading images
*/
initializeObservers() {
// Intersection observer for lazy loading
if ('IntersectionObserver' in window) {
this.imageObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.imageObserver.unobserve(entry.target);
}
});
}, {
rootMargin: this.options.lazyLoadOffset,
threshold: 0.1
});
}
// Resize observer for responsive images
if ('ResizeObserver' in window) {
this.resizeObserver = new ResizeObserver(this.debounce(() => {
this.updateImageSizes();
}, 250));
// Observe the container
this.resizeObserver.observe(this.container);
} else {
// Fallback to window resize
window.addEventListener('resize', this.debounce(() => {
this.updateImageSizes();
}, 250));
}
// Add event delegation
this.container.addEventListener('click', this.handleContainerClick.bind(this));
}
/**
* Handle container click events (delegation pattern)
*/
handleContainerClick(e) {
// Gallery image click
if (this.options.isGallery) {
const feedImage = e.target.closest('.feed-image');
if (feedImage) {
const item = feedImage.closest('.feed-item');
if (item) {
const index = Array.from(this.container.querySelectorAll('.feed-item')).indexOf(item);
if (index !== -1 && this.galleryOpenHandler) {
this.galleryOpenHandler(index);
e.preventDefault();
}
}
}
}
// Favourite button click handled by document-level event listener
}
/**
* Set gallery open handler
*/
setGalleryOpenHandler(handler) {
this.galleryOpenHandler = handler;
}
/**
* Render feed items
*/
renderItems(items, append = false) {
// Reset if not appending
if (!append) {
this.container.innerHTML = '';
this.loadedItems = 0;
this.galleryItems = [];
}
// Add items to gallery if in gallery mode
if (this.options.isGallery) {
this.galleryItems = this.galleryItems.concat(items);
}
// Use DocumentFragment for better performance
const fragment = document.createDocumentFragment();
// Process items in batches for better performance
const batchSize = 10;
const processBatch = (startIndex) => {
const endIndex = Math.min(startIndex + batchSize, items.length);
// Process this batch
for (let i = startIndex; i < endIndex; i++) {
const item = items[i];
const element = this.createItemElement(item);
fragment.appendChild(element);
// Lazy load images beyond threshold
if (this.loadedItems >= this.options.imageLoadThreshold && this.imageObserver) {
this.imageObserver.observe(element);
} else {
this.loadImage(element);
}
this.loadedItems++;
}
// If we have more items, process next batch in next frame
if (endIndex < items.length) {
requestAnimationFrame(() => {
processBatch(endIndex);
});
} else {
// All batches processed, append fragment
this.container.appendChild(fragment);
this.makeItemsKeyboardNavigable();
this.announceUpdate(items.length, append);
}
};
// Start processing the first batch
if (items.length > 0) {
processBatch(0);
} else {
this.announceUpdate(0, append);
}
}
/**
* Load image for an item
*/
loadImage(element) {
const img = element.querySelector('img[data-src]');
if (!img) return;
const size = this.getImageSize();
img.src = img.dataset[size] || img.dataset.src;
delete img.dataset.src;
element.setAttribute('data-loaded', 'true');
}
/**
* Update image sizes based on screen width
*/
updateImageSizes() {
const size = this.getImageSize();
// Update only visible images that aren't already loaded with the right size
const items = this.container.querySelectorAll('.feed-item[data-loaded="true"]');
items.forEach(item => {
const img = item.querySelector('img');
if (img && img.dataset[size] && img.src !== img.dataset[size]) {
img.src = img.dataset[size];
}
});
}
/**
* Create an element for a feed item
*/
createItemElement(item) {
// Process item data - filters out artist from labels if not showing author
if (!this.options.showAuthor) {
item.labels = item.labels.filter(label => label.icon !== 'artist');
if (this.options.showDate) {
item.labels.push({
'icon': 'calendar',
'value': formatTimeAgo(item.date),
'url': false,
});
}
}
// Check if item is favourited
const isFavourited = window.hasFavourited ?
window.hasFavourited(item.type, item.id) :
false;
// Get cached template or create new element
const cacheKey = `${item.type}_template`;
let wrapper;
if (this.templateCache.has(cacheKey)) {
// Clone from template cache
wrapper = this.templateCache.get(cacheKey).cloneNode(true);
} else {
// Create new element
wrapper = document.createElement('details');
wrapper.className = item.type === 'artist' ? 'feed-item artist' : 'feed-item';
// Cache for future use
this.templateCache.set(cacheKey, wrapper.cloneNode(true));
}
// Set unique attributes
wrapper.id = `${item.type}-${item.id}`;
// Add umami tracking attributes
if (item.umami_view) {
const viewAttributes = item.umami_view.split(" ");
viewAttributes.forEach(attr => {
const parts = attr.split('="');
if (parts.length === 2) {
const name = parts[0];
const value = parts[1].replace('"', '');
wrapper.setAttribute(name, value);
}
});
}
// Generate HTML content
const imageHTML = (item.type === 'artist') ? this.generateArtistGrid(item) : this.generateImageHTML(item);
const labelsHTML = this.generateLabels(item.labels);
const taxonomiesHTML = this.generateTaxonomies(item.taxonomies);
wrapper.innerHTML = `
You haven't fallen in love with any pieces... yet!
Hit that heart icon when something stops your scroll.
Your dream collection is waiting to start.
Try tweaking those filters.
Edmonton's got talent - let's find it.