// 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 = `
|
<summary>
|
<span class="handle">DETAILS</span>
|
<button class="favourite-button${isFavourited ? ' favourited' : ''}"
|
data-id="${item.id}"
|
data-type="${item.type}"
|
data-artist="${item.user_id || ''}"
|
title="${isFavourited ? 'Remove from favourites' : 'Add to favourites'}">
|
${window.feedSettings.icons[isFavourited ? 'heart-filled' : 'heart']}
|
</button>
|
${imageHTML}
|
</summary>
|
|
<div class="item-info">
|
${item.title ? `<h3><a href="${item.url}" ${item.umami_click}>${item.title}</a></h3>` : ''}
|
${labelsHTML}
|
${taxonomiesHTML}
|
</div>
|
`;
|
|
return wrapper;
|
}
|
|
generateImageHTML(item) {
|
// For artist with tattoos, show a gallery
|
if (item.type === 'artist' && item.tattoos && item.tattoos.length) {
|
const mainImage = `<a href="${item.url}" class="feed-image" ${item.umami_click}>
|
${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
|
</a>`;
|
|
const tattooHTML = item.tattoos.slice(0, 6).map(tattoo => `
|
<a href="${tattoo.url}" class="tattoo-preview">
|
${tattoo.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
|
</a>
|
`).join('');
|
|
return `
|
<div class="artist-tattoos">
|
${mainImage}
|
${tattooHTML}
|
</div>
|
`;
|
}
|
|
// For regular items, show standard image
|
const url = this.options.isGallery ? '#' : item.url;
|
return `<a href="${url}" class="feed-image" ${item.umami_click}>
|
${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
|
</a>`;
|
}
|
|
/**
|
* Generate artist grid HTML
|
*/
|
generateArtistGrid(item) {
|
const mainImage = `<a href="${item.url}" class="feed-image" ${item.umami_click}>
|
${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')}
|
</a>`;
|
// Add tattoo previews if available
|
if (item.tattoos && item.tattoos.length) {
|
const tattooHtml = item.tattoos.slice(0, 6).map(tattoo => `
|
<a href="${tattoo.url}" class="tattoo-preview">
|
${tattoo.image}
|
</a>
|
`).join('');
|
|
return `
|
<div class="artist-tattoos">
|
${mainImage}
|
${tattooHtml}
|
</div>
|
`;
|
}
|
|
return mainImage;
|
}
|
|
/**
|
* Generate labels HTML
|
*/
|
generateLabels(labels) {
|
if (!labels || !labels.length) return '';
|
|
// Sort labels to show artist first
|
const sortedLabels = [...labels].sort((a, b) => {
|
if (a.icon === 'artist') return -1;
|
if (b.icon === 'artist') return 1;
|
return 0;
|
});
|
|
return `<div class="item-labels">
|
${sortedLabels.map(label => `
|
<div class="label">
|
${window.feedSettings.icons[label.icon]}
|
<span class="screen-reader-text">${label.label || ''}</span>
|
${label.url !== false
|
? `<a href="${label.url}" title="Learn more about ${label.value}" ${label.umami_click || ''}>${label.value}</a>`
|
: label.value
|
}
|
</div>
|
`).join('')}
|
</div>`;
|
}
|
|
/**
|
* Generate taxonomies HTML
|
*/
|
generateTaxonomies(taxonomies) {
|
if (!taxonomies || !taxonomies.length) return '';
|
|
return `<div class="taxonomy-lists">
|
${taxonomies.map(tax => `
|
<div class="taxonomy-group">
|
<span>${window.feedSettings.icons[tax.icon]} ${tax.title}</span>
|
<ul>
|
${tax.terms.slice(0, 3).map(term => `
|
<li>
|
<a href="${term.url}" ${term.umami_click || ''}>${term.title}</a>
|
</li>
|
`).join('')}
|
</ul>
|
</div>
|
`).join('')}
|
</div>`;
|
}
|
|
/**
|
* Show empty state
|
*/
|
showEmptyState(showingFavourites = false) {
|
const message = showingFavourites
|
? `<div class="feed-empty-state">
|
<h3>♡ BLANK CANVAS ♡</h3>
|
<p>You haven't fallen in love with any pieces... yet!</p>
|
<p>Hit that heart icon when something stops your scroll.</p>
|
<p>Your dream collection is waiting to start.</p>
|
</div>`
|
: `<div class="feed-empty-state">
|
<h3>NOTHING HERE...</h3>
|
<p>Try tweaking those filters.</p>
|
<p>Edmonton's got talent - let's find it.</p>
|
</div>`;
|
|
this.container.innerHTML = message;
|
this.announceEmptyState(showingFavourites);
|
}
|
|
/**
|
* Clear the grid
|
*/
|
clear() {
|
this.container.innerHTML = '';
|
this.loadedItems = 0;
|
}
|
|
/**
|
* Get image size based on screen width
|
*/
|
getImageSize() {
|
const width = window.innerWidth;
|
if (width > 1024) return 'full';
|
if (width > 500) return 'medium';
|
return 'small';
|
}
|
|
/**
|
* Announce update to screen readers
|
*/
|
announceUpdate(itemCount, isAppending) {
|
const liveRegion = document.querySelector('.feed-block .live-region');
|
if (liveRegion) {
|
const action = isAppending ? 'Added' : 'Loaded';
|
liveRegion.textContent = `${action} ${itemCount} new items`;
|
}
|
}
|
|
/**
|
* Announce empty state to screen readers
|
*/
|
announceEmptyState(showingFavourites) {
|
const liveRegion = document.querySelector('.feed-block .live-region');
|
if (liveRegion) {
|
liveRegion.textContent = showingFavourites
|
? "No favourites found. Try adding some items to your collection."
|
: "No items found matching your current filters.";
|
}
|
}
|
|
/**
|
* Get gallery items for the gallery modal
|
*/
|
getGalleryItems() {
|
return Array.from(this.container.querySelectorAll('.feed-item'))
|
.map(item => {
|
const img = item.querySelector('img');
|
if (!img) return null;
|
|
return {
|
small: img.dataset.small || img.src,
|
large: img.dataset.medium || img.src,
|
full: img.dataset.full || img.src,
|
alt: img.alt || '',
|
fav: item.querySelector('button.favourite')?.cloneNode(true),
|
info: item.querySelector('.item-info')?.cloneNode(true)
|
};
|
})
|
.filter(Boolean);
|
}
|
|
/**
|
* Make feed items keyboard navigable
|
*/
|
makeItemsKeyboardNavigable() {
|
const items = this.container.querySelectorAll('.feed-item:not([data-keyboard-nav])');
|
|
items.forEach(item => {
|
// Mark as processed
|
item.setAttribute('data-keyboard-nav', 'true');
|
|
// Make focusable
|
item.setAttribute('tabindex', '0');
|
|
// Add keyboard handling
|
item.addEventListener('keydown', e => {
|
if (e.key === 'Enter' || e.key === ' ') {
|
// Find and click main link
|
const link = item.querySelector('.feed-image a, h3 a');
|
if (link) {
|
link.click();
|
e.preventDefault();
|
}
|
}
|
});
|
});
|
}
|
|
/**
|
* Update favourite status for items
|
*/
|
updateFavouriteStatus(type, id, isFavourited) {
|
// Find all matching buttons in the grid
|
const buttons = this.container.querySelectorAll(
|
`button.favourite[data-type="${type.replace('jvb_', '')}"][data-id="${id}"]`
|
);
|
|
// Update button state
|
buttons.forEach(button => {
|
button.classList.toggle('favourited', isFavourited);
|
button.innerHTML = window.feedSettings.icons[isFavourited ? 'heart-filled' : 'heart'];
|
button.title = isFavourited ? 'Remove from favourites' : 'Add to favourites';
|
});
|
}
|
|
/**
|
* Clean up resources when component is destroyed
|
*/
|
destroy() {
|
// Disconnect observers
|
if (this.imageObserver) {
|
this.imageObserver.disconnect();
|
this.imageObserver = null;
|
}
|
|
if (this.resizeObserver) {
|
this.resizeObserver.disconnect();
|
this.resizeObserver = null;
|
}
|
|
// Remove event listeners
|
this.container.removeEventListener('click', this.handleContainerClick);
|
window.removeEventListener('resize', this.debounce);
|
|
// Clear template cache
|
this.templateCache.clear();
|
|
// Clear data
|
this.galleryItems = [];
|
this.loadedItems = 0;
|
this.galleryOpenHandler = null;
|
}
|
|
/**
|
* Debounce function to limit frequent calls
|
*/
|
debounce(func, wait) {
|
let timeout;
|
return function(...args) {
|
clearTimeout(timeout);
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
};
|
}
|
|
}
|
|
export default FeedGrid;
|