// 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 = ` DETAILS ${imageHTML}
${item.title ? `

${item.title}

` : ''} ${labelsHTML} ${taxonomiesHTML}
`; return wrapper; } generateImageHTML(item) { // For artist with tattoos, show a gallery if (item.type === 'artist' && item.tattoos && item.tattoos.length) { const mainImage = ` ${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')} `; const tattooHTML = item.tattoos.slice(0, 6).map(tattoo => ` ${tattoo.image.replace(/src="([^"]+)"/, 'data-src="$1"')} `).join(''); return `
${mainImage} ${tattooHTML}
`; } // For regular items, show standard image const url = this.options.isGallery ? '#' : item.url; return ` ${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')} `; } /** * Generate artist grid HTML */ generateArtistGrid(item) { const mainImage = ` ${item.image.replace(/src="([^"]+)"/, 'data-src="$1"')} `; // Add tattoo previews if available if (item.tattoos && item.tattoos.length) { const tattooHtml = item.tattoos.slice(0, 6).map(tattoo => ` ${tattoo.image} `).join(''); return `
${mainImage} ${tattooHtml}
`; } 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 `
${sortedLabels.map(label => `
${window.feedSettings.icons[label.icon]} ${label.label || ''} ${label.url !== false ? `${label.value}` : label.value }
`).join('')}
`; } /** * Generate taxonomies HTML */ generateTaxonomies(taxonomies) { if (!taxonomies || !taxonomies.length) return ''; return `
${taxonomies.map(tax => `
${window.feedSettings.icons[tax.icon]} ${tax.title}
`).join('')}
`; } /** * Show empty state */ showEmptyState(showingFavourites = false) { const message = showingFavourites ? `

♡ BLANK CANVAS ♡

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.

` : `

NOTHING HERE...

Try tweaking those filters.

Edmonton's got talent - let's find it.

`; 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;