class FeedBlock { static LOADING_QUIPS = JSON.parse(feedSettings.quips); constructor(container){ this.cache = window.jvbCache; this.eventHandlers = new Map(); this.rendered = {}; // Store container references this.container = container; this.a11y = window.jvbA11y; //For tracking cache and current requests this.currentRequest = null; this.timeoutId = null; // Initialize Config this.initConfig(); // Setup error handler this.error = window.jvbError; this.resetState(); this.state.firstLoad = false; this.state.URLProcessed = false; this.highlightGot = false; this.selectorInstances = {}; this.elements = { filters: this.container.querySelector('form.feed-filters'), selected: this.container.querySelector('.selected-items'), clearFilters: this.container.querySelector('button.clear-filters'), grid: this.container.querySelector('.feed-grid'), loadMore: this.container.querySelector('button.load-more'), spinner: this.container.querySelector('.loading-spinner'), loading: this.container.querySelector('.feed-overlay'), matchAll: this.container.querySelector('.filter-actions .toggle-text'), } this.filters = { content: this.getCurrentContent(), taxonomies: {}, favourites: false, orderby: 'date', order: 'desc', } this.feed = { imageLoadThreshold: 5, lazyLoadOffset: '100px', gallery: [], loaded: 0, intersectionObserver: null, templates: new Map() } this.imageObserver = null; this.resizeObserver = null; this.highlight = null; //Loading settings this.loadingIndex = 0; this.quips = this.initializeQuips(); this.loadingMessage = this.container.querySelector('.loading-message'); this.dotsElement = this.container.querySelector('.loading-dots'); this.quipInterval = null; this.loadingOptions = { loadingMessages: {}, cycleInterval: 2000, //time between loading messages } this.initFilters(); if(this.container.dataset.gallery){ this.initGallery(); this.setupGalleryAccessibility(); } this.initListeners(); if(!this.state.URLProcessed){ this.updateFilters(); } this.selectedListeners = this.checkSelectedClicks.bind(this); this.updateSelectedListeners(); } initConfig() { // Get settings from container data attribute const settings = JSON.parse(this.container.dataset.settings || '{}'); let content = Array.from(this.container.querySelectorAll('input[name="content"]')).map(content=> content.value); this.config = { api: feedSettings.apiUrl, nonce: feedSettings.nonce, currentUser: jvbSettings.currentUser || null, content: content[0], contentTypes: content, taxonomies: Array.from(this.container.querySelectorAll('.jvb-selector')).map(taxonomy => taxonomy.dataset.taxonomy), // Source information for analytics source: this.container.dataset.source || '', context: this.container.dataset.context || '', // Optional highlight highlight: null, // Gallery mode isGallery: this.container.dataset.gallery || false, showAuthor: !this.container.dataset.gallery || true, showDate: this.container.dataset.gallery || false, // User preferences viewMode: localStorage.getItem('feedViewMode') || 'grid', } if(settings.isGallery){ this.config.highlight = this.getHighlight(); } } initFilters(){ this.updateContentFor(this.getCurrentContent()); } checkSelectedClicks(e){ if(e.target.closest('.remove-item')){ let tag = e.target.closest('.selected-item'); // Uncheck checkbox if it exists let taxonomy = tag.dataset.taxonomy; this.clearSelectedTerm(tag.dataset.id, taxonomy); // Remove tag tag.remove(); // Update clear filters button visibility this.updateClearFiltersButton(); this.updateFilters(); this.updateSelectedListeners(); } } updateSelectedListeners(){ this.elements.selected.removeEventListener('click', this.selectedListeners); if(this.elements.selected.children.length>0){ this.elements.selected.addEventListener('click', this.selectedListeners); } } handleFilterChange(e){ if(e.target.closest('.jvb-selector')){ return; } this.resetPage(); if(e.target.name === 'content'){ let content = e.target.value; this.updateContentFor(content); } this.updateFilters(); } handleLoadMore(){ if (this.state.loading || !this.state.hasMore) { return; } this.fetchFeed(); } initListeners(){ window.addEventListener('popstate', this.handlePopState.bind(this)); this.addEvent(this.elements.filters, 'change', (e) => this.handleFilterChange(e)); this.addEvent(this.elements.filters, 'submit', e => e.preventDefault()); this.addEvent(this.elements.loadMore, 'click', () => this.handleLoadMore()); this.addEvent(this.elements.clearFilters, 'click', () => this.clearSelectedTaxonomies()); if(this.config.isGallery){ this.addEvent(this.elements.grid, 'click', (e) =>this.handleGalleryOpen(e)); } // 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.feed.lazyLoadOffset, threshold: 0.1 }); } // Resize observer for responsive images if ('ResizeObserver' in window) { this.resizeObserver = new ResizeObserver(window.debounce(() => { this.updateImageSizes(); }, 250)); // Observe the container this.resizeObserver.observe(this.container); } else { // Fallback to window resize window.addEventListener('resize', window.debounce(() => { this.updateImageSizes(); }, 250)); } } handleGalleryOpen(e){ 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.openGallery(index); } } } } initGallery(){ this.gallery = { items: this.getGalleryItems() || [], index: 0, touchStart: null, touchEnd: null, minSwipe: 50, modal: this.createGalleryModal(), keyHandler: null, loading: false } document.body.appendChild(this.gallery.modal); } /** * Handle browser history navigation */ handlePopState(e) { if (e.state && e.state.filters) { if(this.processURLFilters()){ // Load items with updated filters this.resetPage(); this.fetchFeed(); // Announce to screen readers this.a11y.announce('Feed filters updated from browser history.'); } } } processURLFilters(){ const params = new URLSearchParams(window.location.search); if (!params.toString()) { return false; // No parameters to process } // Initialize content type const content = params.get('f_content'); params.delete('f_content'); if (content && this.elements.filters.querySelector(`input[value="${content}"]`)) { this.elements.filters.querySelector(`input[value="${content}"]`).checked = true; this.filters.content = content; } // Initialize order const order = params.get('f_order'); params.delete('f_order'); if (order && this.elements.filters.querySelector(`input[name="order"][value="${order}"]`)) { this.elements.filters.querySelector(`input[name="order"][value="${order}"]`).checked = true; this.filters.order = order; } // Initialize orderby const orderby = params.get('f_orderby'); params.delete('f_orderby'); if (orderby && this.elements.filters.querySelector(`input[name="orderby"][value="${orderby}"]`)) { this.elements.filters.querySelector(`input[name="orderby"][value="${orderby}"]`).checked = true; this.filters.orderby = orderby; } // Initialize favourites filter if (params.get('f_favourites') === 'true' && this.config.currentUser !== null) { params.delete('f_favourites'); const favCheckbox = this.elements.filters.querySelector(`input[name="favourites_only"]`); if (favCheckbox) favCheckbox.checked = true; this.filters.favourites = true; } // Initialize match, if present if(params.get('f_match') === 'all'){ params.delete('f_match'); this.elements.matchAll.querySelector('input').checked = true; } window.removeChildren(this.elements.selected); let filters = JSON.parse(JSON.stringify(this.filters)); let unprocessed = {}; for (var [key, value] of Object.entries(Object.fromEntries(params))) { if (key.startsWith('f_') ) { var taxName = key.replace('f_', ''); let cache = this.cache.getItem(taxName+'List'); if(!Object.keys(filters['taxonomies']).includes(taxName)){ filters.taxonomies[taxName] = {}; } // Handle both single values and comma-separated values const termIds = value.includes(',') ? value.split(',') : [value]; termIds.forEach(termId=>{ if(cache && cache.hasOwnProperty(termId)){ filters.taxonomies[taxName][termId] = cache[termId].name; let tag = this.createFilterTag(taxName, termId, cache[termId].name); this.elements.selected.appendChild(tag); }else{ if(!unprocessed.hasOwnProperty(taxName)){ unprocessed[taxName] = []; } unprocessed[taxName].push(termId); } }); } } this.filters = filters; if(!isEmptyObject(unprocessed)){ this.fetchTermDetails(unprocessed); } if(this.config.isGallery){ this.getHighlight(); } this.updateContentFor(content); this.updateSelectedListeners(); return true; } async loadFromURL() { if(this.processURLFilters()){ // Announce to screen readers this.a11y.announce('Feed filters updated from URL.'); } return true; } //We already checked the cache, now fetch the remaining term information async fetchTermDetails(terms) { let params = new URLSearchParams(terms); // Otherwise fetch the term details try { // Format the URL - might need to adjust based on your API structure const url = `${this.config.api}terms/check?`+params.toString(); const termData = await this.cache.fetchWithCache( url, { method: 'GET', }, { }); for(const [taxonomy, terms] of Object.entries(termData.terms)){ if(!this.filters.taxonomies.hasOwnProperty(taxonomy)){ this.filters.taxonomies[taxonomy] = {}; } this.cache.setItem(taxonomy+'List', terms, taxonomy); for(const [termId, termData] of Object.entries(terms)){ this.filters.taxonomies[taxonomy][termId] = termData.name; let tag = this.createFilterTag(taxonomy, termId, termData.name); this.elements.selected.appendChild(tag); } this.updateSelectedListeners(); } } catch (error) { console.error(`Error fetching term details for ${terms}:`, error); } } //State Management nextPage(hasMore = true){ this.state.page++; this.state.hasMore = hasMore; } resetPage(hasMore = true){ this.state.page = 1; this.state.hasMore = hasMore; } resetState() { this.state = { page: 1, loading: false, hasMore: true, retries: { count: 0, max: 3, delay: 1000 } } } updateFilters(reset = true){ let updated = false; let content = this.getCurrentContent(); let favourites = this.getFavouritesOnly(); let order = this.getCurrentOrder(); let orderby = this.getCurrentOrderby(); let taxonomies = this.getSelectedTaxonomies(); if(this.filters.content !== content || this.filters.favourites !== favourites || this.filters.order !== order || this.filters.orderby !== orderby || this.filters.taxonomies !== taxonomies ){ updated = true; } (this.filters.content !== content)??this.updateContentFor(this.filters.content); this.filters.content = content; this.filters.favourites = favourites; this.filters.order = order; this.filters.orderby = orderby; this.filters.taxonomies = taxonomies; if(this.state.firstLoad){ this.updateURL(); }else{ this.state.firstLoad = true; } if(reset){ this.resetPage(); } this.fetchFeed(); } getCurrentContent(){ return this.elements.filters.querySelector('input[name="content"]:checked').value; } getFavouritesOnly(){ return this.elements.filters.querySelector('input[name="favourites_only"]')?.checked ??false; } getCurrentOrder(){ return this.elements.filters.querySelector('input[name="order"]:checked').value; } getCurrentOrderby(){ return this.elements.filters.querySelector('input[name="orderby"]:checked').value; } getCurrentTaxonomies(){ let taxonomies = this.filters.taxonomies; let out = {}; if(!isEmptyObject(taxonomies)){ for(var [tax, terms] of Object.entries(taxonomies)){ if(!isEmptyObject(terms)){ out[tax] = Object.values(terms); } } } return out; } updateContentFor(content){ this.elements.filters.querySelectorAll('.jvb-selector').forEach(tax=> { tax.hidden = !tax.dataset.for.includes(content); //Ensure any selected Taxonomies are removed if(!tax.dataset.for.includes(content)){ this.clearSelectedTerms(tax); } //TODO: Ensure clean up of filtered out window.jvbTaxonomySelector instances? //Maybe cache them? let taxonomy = tax.dataset.taxonomy; if(tax.dataset.for.includes(content)){ if(!this.selectorInstances.hasOwnProperty(taxonomy)){ this.selectorInstances[taxonomy] = new window.jvbTaxonomySelector(tax, { multiple: true, feed: true, selected: this.filters[taxonomy]??{}, onClose: () => this.setSelectedTerms(taxonomy) }); } }else if(this.selectorInstances.hasOwnProperty(taxonomy)){ this.clearSelectedTerms(taxonomy); delete this.selectorInstances[taxonomy]; } }); this.elements.filters.querySelectorAll('input[data-for]').forEach(toggle=>{ toggle.hidden = !toggle.dataset.for.includes(content); }); this.elements.filters.querySelectorAll('input[name="order"]').forEach(order=>{ order.hidden = this.filters.orderby === 'random'; }); } updateURL(){ const params = new URLSearchParams(); let taxonomies = this.filters.taxonomies; if(!isEmptyObject(taxonomies)){ for(var [tax, terms] of Object.entries(taxonomies)){ if(!isEmptyObject(terms)){ params.set('f_'+tax, Object.keys(terms)); } } } // Clone to avoid modifying original let filters = JSON.parse(JSON.stringify(this.filters)); delete filters.taxonomies; for(const [key, value] of Object.entries(filters)){ if(value !== false){ params.set('f_'+key, value); } } if(this.elements.matchAll.querySelector('input:checked')){ params.set('f_match', 'all'); } const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`; history.pushState({ filters }, '', newUrl); } /** * Feed Fetching * @param reset clear grid * @param force force cache busting * @returns {Promise} */ async fetchFeed(reset = false, force = false){ if(this.state.loading){ return; } this.updateLoading(true); try{ // Track URL processing if(this.state.page === 1){ reset = true; await this.loadFromURL(); } const params = this.buildFilterRequest(); if(this.elements.matchAll.querySelector('input:checked')){ params.append('match', true); } params.append('page', this.state.page); params.append('source', this.config.source); params.append('context', this.config.context); if(this.config.highlight){ params.append('highlight', JSON.stringify(this.config.highlight)); } //fetch data with cache busting const data = await this.cache.fetchWithCache( `${this.config.api}feed?${params.toString()}`, { method: 'GET', }, { context: 'feed', forceRefresh: true, // forceRefresh: force, } ) console.log(data, 'Fetched data: '); //Clear grid on first page if(this.state.page === 1){ window.removeChildren(this.elements.grid); } //Handle empty results if(!data || !data.items || data.items.length === 0){ if(this.state.page === 1){ this.showEmptyState(); } this.state.hasMore = false; }else{ this.state.hasMore = data.hasMore; if(this.state.hasMore){ this.nextPage(); } //Render items this.renderItems(data.items, this.state.page > 1); } }catch(error){ this.handleError(error); }finally{ this.updateLoading(false); this.elements.loadMore.hidden = !this.state.hasMore; } } buildFilterRequest(){ // Clone to avoid modifying original const filters = JSON.parse(JSON.stringify(this.filters)); if(filters.taxonomies && !isEmptyObject(filters.taxonomies)){ let temp = {}; for(var [tax, terms] of Object.entries(filters.taxonomies)){ if(!isEmptyObject(terms)){ temp[tax] =Object.keys(terms); } } delete filters.taxonomies; if(!isEmptyObject(temp)){ filters.taxonomies = JSON.stringify(temp); } }else{ delete filters.taxonomies; } if(!filters.favourites){ delete filters.favourites; } return new URLSearchParams(filters); } handleError(error){ return this.error.handleApiError( error, { component: 'Feed Block', action: 'loaditems' }, () => this.fetchFeed() ); } getHighlight() { if(!this.config.highlight){ const searchParams = new URLSearchParams(window.location.search); // Check for content type parameters this.config.contentTypes.forEach(type => { if (searchParams.has(type)) { this.config.highlight = {}; this.config.highlight[type] = searchParams.get(type); } }); } return this.config.highlight; } /** * Loading */ updateLoading(loading) { this.state.loading = loading; if (loading) { this.showLoading(); let content = this.filters.content; content = (content === 'artwork') ? content : content+'s'; let tax = ''; let taxonomies = this.getCurrentTaxonomies(); if(!isEmptyObject(taxonomies)){ let total = 0; total = Object.values(taxonomies).map((tax)=> total+tax.length); let all = []; let join = (total[0] === 2) ? ' and ' : ', '; tax = Object.values(taxonomies).map((tax) => all.push(tax.join(join))); tax = all.join(', '); let index = tax.lastIndexOf(',')+1; if(index> 0){ tax = tax.substr(0, index)+' and'+tax.substr(index); } } this.a11y.announce(`Checking for more ${tax} ${content}...`); } else { this.hideLoading(); } // Update loading spinner this.elements.spinner.hidden = !loading; // Update load more button this.elements.loadMore.disabled = loading; } /** * Show the loading overlay */ showLoading() { this.hideBody(); this.elements.loading.classList.add('active'); this.startQuipCycle(); document.body.classList.add('loading'); } /** * Hide the loading overlay */ hideLoading() { this.showBody(); this.container.classList.remove('active'); this.stopQuipCycle(); document.body.classList.remove('loading'); } /** * Start cycling through loading messages */ startQuipCycle() { if (this.quipInterval) { clearInterval(this.quipInterval); } if (!this.quips.length) return; // Set initial message this.updateMessage(this.quips[0]); this.container.classList.remove('changing'); this.quipInterval = setInterval(() => { this.container.classList.add('changing'); setTimeout(() => { this.loadingIndex = (this.loadingIndex + 1) % this.quips.length; this.updateMessage(this.quips[this.loadingIndex]); setTimeout(() => { this.container.classList.remove('changing'); }, 50); }, 350); }, this.loadingOptions.cycleInterval); } /** * Stop cycling through loading messages */ stopQuipCycle() { if (this.quipInterval) { clearInterval(this.quipInterval); this.quipInterval = null; } } /** * Update the loading message */ updateMessage(quipData) { if (!this.loadingMessage) return; const icon = feedSettings?.icons?.[quipData.icon] || ''; this.loadingMessage.innerHTML = `${icon}

${quipData.quip}

`; } /** * Set a custom loading message */ setMessage(message, icon = null) { if (!this.loadingMessage) return; this.stopQuipCycle(); const iconHtml = icon ? feedSettings?.icons?.[icon] || '' : ''; this.loadingMessage.innerHTML = `${iconHtml}

${message}

`; } /** * Shuffle an array (Fisher-Yates algorithm) */ shuffleArray(array) { const newArray = [...array]; for (let i = newArray.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; } return newArray; } /** * Initialize quips for loading messages */ initializeQuips() { // Map pstyle, artstyle, etc. to their base types for quips const typeMapping = { 'pstyle': 'style', 'artstyle': 'style', 'arttheme': 'theme', 'artmedium': 'style', 'artform': 'style', 'placement': 'style', 'colour': 'style' }; // Gather all quips based on content types and taxonomies const allQuips = []; // Add content type quips if (FeedBlock.LOADING_QUIPS[this.filters.content]) { FeedBlock.LOADING_QUIPS[this.filters.content].forEach(quip => { allQuips.push({ icon: this.filters.content, quip: quip }); }); } // Add taxonomy quips if(this.filters.taxonomies && !isEmptyObject(this.filters.taxonomies)){ Object.keys(this.filters.taxonomies).forEach(taxonomy => { const baseType = typeMapping[taxonomy] || taxonomy; if (FeedBlock.LOADING_QUIPS[baseType]) { FeedBlock.LOADING_QUIPS[baseType].forEach(quip => { allQuips.push({ icon: taxonomy, quip: quip }); }); } }); } // Shuffle the quips array return this.shuffleArray(allQuips); } /** * Feed Grid */ renderItems(items, append = false) { // Reset if not appending if (!append) { window.removeChildren(this.elements.grid); this.feed.loaded = 0; this.feed.gallery = []; } // Add items to gallery if in gallery mode if (this.config.isGallery) { this.feed.gallery = this.feed.gallery.concat(items); } // Bail early if no items if (items.length === 0) { this.a11y.announceUpdate(0, append); return; } // 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.feed.loaded >= this.feed.imageLoadThreshold && this.imageObserver) { this.imageObserver.observe(element); } else { this.loadImage(element); } this.feed.loaded++; } // 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.elements.grid.appendChild(fragment); if(this.container.dataset.gallery){ console.log(this.getGalleryItems()); this.updateGalleryItems(this.getGalleryItems()); //pagination already updated, so it'll be page 2 if(this.getHighlight() && !this.highlightGot){ this.highlightGot = true; this.openGallery(0); } } this.a11y.makeNavigable(this.elements.grid.querySelectorAll('.feed-item:not([data-keyboard-nav])')); this.a11y.announceItems(items.length, append, this.state.hasMore); } }; // Start processing the first batch if (items.length > 0) { processBatch(0); } else { this.a11y.announceUpdate(0, append); } } /** * Load image for an item */ loadImage(element) { const img = element.querySelector('img'); 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.elements.grid.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) { if(!this.rendered[item.icon]){ this.rendered[item.icon] = new Map(); } if(this.rendered[item.icon].has(item.id)){ return this.rendered[item.icon].get(item.id); } const favourited = window.isFavourited(item.icon, item.id)??false; const template = window.getTemplate('feed-item'); // Set unique attributes template.id = `${item.icon}-${item.id}`; template.classList.add(item.icon); if (item.umami_view) { this.buildUmamiData(template, item.umami_view); } let favouriteButton = template.querySelector('button.favourite'); [ favouriteButton.dataset.id, favouriteButton.dataset.type, favouriteButton.dataset.artist, favouriteButton.title ] = [ item.id, item.icon, item.user_id, (favourited) ? 'Remove from Favourites' : 'Add to Favourites' ]; let order = item.order; let single = template.querySelector('.item'); let list = template.querySelector('.item-list'); let img = template.querySelector('.feed-images'); let summary = template.querySelector('summary'); let info = template.querySelector('.item-info'); for (let [index, id] of Object.entries(order)){ let target; let config = item[id]; if (id === 'title') { target = template.querySelector('h3 a'); if (item.title !== '') { [ target.textContent, target.href, target.url ] = [ item.title, item.url, `Learn more about this ${item.icon}` ]; if (item.icon !== '') { target.closest('h3').prepend(window.getIcon(item.icon)); } if (item.umami_click) { this.buildUmamiData(target, item.umami_click); } } else { target.remove(); } } else if (Object.hasOwn(config, 'terms')) { //Taxonomy list if (config.terms.length === 0) { continue; } let taxonomy = list.cloneNode(true); let label = taxonomy.querySelector('.label'); let termList = taxonomy.querySelector('ul'); let listItem = taxonomy.querySelector('li'); if (config.label) { label.textContent = config.label; } if (config.icon) { label.prepend(window.getIcon(config.icon)); } if (!config.label && !config.icon){ label.remove(); } config.terms.forEach(term => { let termItem = listItem.cloneNode(true); let link = termItem.querySelector('a'); [ link.href, link.title, link.textContent ] = [ term.url, `Learn more about ${term.title}`, term.title ]; if (term.umami_click.length > 0) { this.buildUmamiData(link, term.umami_click); } termList.append(termItem); }); listItem.remove(); info.appendChild(taxonomy); } else if (Object.hasOwn(config, 'value') && config.value !== '') { let itemInfo = single.cloneNode(true); let label = itemInfo.querySelector('.label'); let link = itemInfo.querySelector('a'); let p = itemInfo.querySelector('p'); if (Object.hasOwn(config, 'label')) { label.textContent = config.label; } if (Object.hasOwn(config, 'icon')) { label.prepend(window.getIcon(config.icon)); } if (!Object.hasOwn(config, 'icon') && !Object.hasOwn(config, 'label')) { label.remove(); } if (Object.hasOwn(config, 'url')) { p.remove(); [ link.textContent, link.href, link.title ] = [ config.value, config.url, `Learn more about ${config.value}` ]; } else { link.remove(); p.textContent = config.value; } info.appendChild(itemInfo); } else if (id === 'image') { let images = summary.querySelector('.feed-images'); let img = images.querySelector('a'); let main = img.cloneNode(true); main.href = item.url; main.classList.add('feed-image'); this.buildImageData(main.querySelector('img'), item.image); images.append(main); if (item.content?.length > 0) { images.classList.add('multi'); item.content.forEach(c => { let image = img.cloneNode(true); image.href = c.url; let itemImg = image.querySelector('img'); itemImg.src = c.image.small; itemImg.alt = c.image.alt; images.append(image); }); } img.remove(); } } single.remove(); list.remove(); this.rendered[item.icon].set(item.id, template); return template; } buildImageData(img, data){ if (typeof data.tiny !== 'string') { return; } [ img.src, img.dataset.small, img.dataset.medium, img.dataset.large, img.alt ] = [ data.tiny, data.small, data.medium, data.large, data.alt ]; } buildUmamiData(item, data){ for(let [key, value] of Object.entries(data)){ item.dataset[key] = value; } } /** * Show empty state */ showEmptyState() { const message = this.filters.favourites ? `

♡ 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.elements.grid.innerHTML = message; this.a11y.announceEmpty(this.filters.favourites); } /** * Clear the grid */ clearGrid() { this.a11y.announce('Items cleared.'); window.removeChildren(this.elements.grid); this.feed.loaded = 0; } /** * Get image size based on screen width */ getImageSize() { const width = window.innerWidth; if (width > 1024) return 'medium'; if (width > 500) return 'medium'; return 'small'; } /** * 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 { id: item.querySelector('button.favourite').dataset.id, 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); } /** * Clean up resources when component is destroyed */ addEvent(element, event, handler, options) { if (!element) return; const boundHandler = handler.bind(this); element.addEventListener(event, boundHandler, options); // Store for cleanup if (!this.eventHandlers.has(element)) { this.eventHandlers.set(element, []); } this.eventHandlers.get(element).push({ event, handler: boundHandler }); } destroy() { // Clean up observers if (this.imageObserver) { this.imageObserver.disconnect(); this.imageObserver = null; } if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } // Clean up all event listeners this.eventHandlers.forEach((handlers, element) => { handlers.forEach(({ event, handler }) => { element.removeEventListener(event, handler); }); }); this.eventHandlers.clear(); // Clean up timers if (this.quipInterval) { clearInterval(this.quipInterval); } if (this.timeoutId) { clearTimeout(this.timeoutId); } // Clear template cache and other state this.feed.templates.clear(); this.feed.gallery = []; this.feed.loaded = 0; } /** Extra Term Handling **/ getSelectedTaxonomies(){ let taxonomies = {}; for(var [taxonomy, instance] of Object.entries(this.selectorInstances)){ taxonomies[taxonomy] = instance.selectedItems; } return taxonomies; } /** * Get selected values for a taxonomy */ getSelectedTerms(taxonomy) { const selectedItems = this.elements.selected.querySelectorAll( `.selected-item[data-taxonomy="${taxonomy}"]` ); return Array.from(selectedItems).map(item => item.dataset.id); } clearSelectedTaxonomies(){ window.removeChildren(this.elements.selected); if(!isEmptyObject(this.selectorInstances)){ for(var [taxonomy, instance] of Object.entries(this.selectorInstances)){ instance.selectedItems = {}; } } this.elements.matchAll.querySelector(input).checked = false; this.filters.taxonomies = {}; this.updateFilters(); } setSelectedTerms(taxonomy){ if(this.selectorInstances[taxonomy]){ let selected = this.selectorInstances[taxonomy].selectedItems; if(!isEmptyObject(selected)){ this.filters.taxonomies[taxonomy] = selected; for(var [id, name] of Object.entries(selected)){ this.elements.selected.appendChild(this.createFilterTag(taxonomy, id, name)); } this.updateFilters(); this.updateSelectedListeners(); }else{ delete this.filters.taxonomies[taxonomy]; } } } clearSelectedTerm(termId, taxonomy){ let container = this.container.querySelector('.filters'); let input = container.querySelector(`li[data-id="${termId}"] input`); input.checked = false delete this.selectorInstances[taxonomy].selectedItems[termId]; } clearSelectedTerms(taxonomy){ if(!isEmptyObject(this.filters.taxonomies) && this.filters.taxonomies.hasOwnProperty(taxonomy)){ delete this.filters.taxonomies[taxonomy]; } if(!isEmptyObject(this.selectorInstances) && this.selectorInstances.hasOwnProperty(taxonomy)){ this.selectorInstances[taxonomy].selectedItems = {}; } const selectedItems = this.elements.selected.querySelectorAll( `.selected-item[data-taxonomy="${taxonomy}"]` ); if(selectedItems.length > 0){ selectedItems.forEach(item => { item.remove(); }); } // Update clear filters button visibility this.updateClearFiltersButton(); } updateClearFiltersButton(){ if (!this.elements.clearFilters) return; let filters = this.elements.selected.children.length; const hasFilters = filters > 0; const hasMultiple = filters > 1; this.elements.clearFilters.hidden = !hasFilters; this.elements.filters.classList.toggle('has-filters', hasFilters); this.elements.matchAll.hidden = !hasMultiple; } /** * Create a filter tag element */ createFilterTag(taxonomy, id, name) { const tag = window.getTemplate('selectedTerm'); tag.dataset.taxonomy = taxonomy; tag.dataset.id = id; let icon = window.getIcon(taxonomy); let span = tag.querySelector('span'); let button = tag.querySelector('button'); tag.prepend(icon); span.classList.add('filter-name'); span.classList.remove('item-name'); [span.textContent, button.remove] = [escapeHtml(name), `Remove ${escapeHtml(name)}`]; return tag; } /** * Gallery **/ openGallery(index){ this.gallery.index = index; this.gallery.modal.showModal(); this.hideBody(); this.bindGalleryEvents(); //show current image this.updateDisplay(index); //preload adjacent images this.preloadImages(); // Announce initial state this.a11y.announce(`Image ${this.gallery.index + 1} of ${this.gallery.items.length}. Use arrow keys to navigate.`) this.a11y.trapFocus(this.gallery.modal); } /** * Create the modal element */ createGalleryModal() { const modal = document.createElement('dialog'); modal.className = 'gallery-modal'; modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', 'Image Gallery'); modal.innerHTML = ` `; return modal; } /** * Bind event handlers */ bindGalleryEvents() { // Close button this.gallery.modal.querySelector('.gallery-close').addEventListener('click', () => this.closeGallery()); // Navigation buttons const prevBtn = this.gallery.modal.querySelector('.gallery-prev'); const nextBtn = this.gallery.modal.querySelector('.gallery-next'); prevBtn.addEventListener('click', () => this.navigate(-1)); nextBtn.addEventListener('click', () => this.navigate(1)); // Keyboard navigation this.gallery.keyHandler = (e) => { switch (e.key) { case 'ArrowLeft': this.navigate(-1); break; case 'ArrowRight': this.navigate(1); break; case 'Escape': this.closeGallery(); break; } }; document.addEventListener('keydown', this.gallery.keyHandler); // Touch events this.gallery.modal.addEventListener('touchstart', (e) => { this.gallery.touchStart = e.touches[0].clientX; }); this.gallery.modal.addEventListener('touchmove', (e) => { this.gallery.touchEnd = e.touches[0].clientX; }); this.gallery.modal.addEventListener('touchend', () => { if (!this.gallery.touchStart || !this.gallery.touchEnd) return; const distance = this.gallery.touchStart - this.gallery.touchEnd; const isLeftSwipe = distance > this.gallery.minSwipe; const isRightSwipe = distance < -this.gallery.minSwipe; if (isLeftSwipe) { this.navigate(1); } else if (isRightSwipe) { this.navigate(-1); } this.gallery.touchStart = null; this.gallery.touchEnd = null; }); } /** * Navigate to previous/next image */ async navigate(direction) { const newIndex = this.gallery.index + direction; // Check if out of bounds if (newIndex < 0 || newIndex >= this.gallery.items.length) { this.a11y.announceNavigation(newIndex, this.gallery.items.length,direction < 0, direction > 0) return; } // Update current index this.gallery.index = newIndex; // Update display this.updateDisplay(newIndex); // Preload adjacent images this.preloadImages(); // Announce to screen readers this.a11y.announceNavigation(this.gallery.index,this.gallery.items.length); //Load more if we're near the end if (direction > 0 && newIndex >= (this.gallery.items.length - 3) && this.state.hasMore) { await this.fetchFeed(); this.updateGalleryItems(this.getGalleryItems()); } } /** * Preload adjacent images */ preloadImages() { // Preload current, previous and next images [-1, 0, 1].forEach(offset => { const index = this.gallery.index + offset; if (index >= 0 && index < this.gallery.items.length) { const img = new Image(); const item = this.gallery.items[index]; if (window.innerWidth < 1000) { img.src = item.large || item.src; } else { img.src = item.full || item.src; } } }); } /** * Update display with current image */ updateDisplay(index) { const item = this.gallery.items[index]; if (!item) return; // Get elements const favourite = this.gallery.modal.querySelector('.gallery-favourite'); const image = this.gallery.modal.querySelector('.gallery-image'); const counter = this.gallery.modal.querySelector('.gallery-counter'); const info = this.gallery.modal.querySelector('.item-info'); // Update image image.src = window.innerWidth < 1000 ? (item.large || item.src) : (item.full || item.src); image.alt = item.alt || ''; // Update favourite button if (favourite && item.fav) { window.removeChildren(favourite); favourite.appendChild(item.fav.cloneNode(true)); } // Update info if (info && item.info) { window.removeChildren(info); const clone = item.info.cloneNode(true); info.appendChild(clone); } // Update counter counter.textContent = `${this.gallery.index + 1} / ${this.gallery.items.length}`; // Update navigation buttons this.updateNavigationButtons(); } /** * Update navigation button visibility */ updateNavigationButtons() { const prevBtn = this.gallery.modal.querySelector('.gallery-prev'); const nextBtn = this.gallery.modal.querySelector('.gallery-next'); prevBtn.classList.toggle('end', this.gallery.index > 0 ? '' : 'none'); nextBtn.classList.toggle('end', this.gallery.index < this.gallery.items.length - 1 ? '' : 'none'); } /** * Close the gallery */ closeGallery() { this.showBody(); // Remove event listeners document.removeEventListener('keydown', this.gallery.keyHandler); this.a11y.announce('Gallery closed.'); this.gallery.modal.close(); // Reset state this.gallery.keyHandler = null; } /** * Update gallery items * @param {Array} newItems - New gallery items */ updateGalleryItems(newItems) { // Store original current index and item const currentItem = this.gallery.items[this.gallery.index]; // Update items array this.gallery.items = newItems; // Try to keep the same item selected if (currentItem) { // Find the same item in the new array by matching source const newIndex = this.gallery.items.findIndex(item => item.full === currentItem.full || item.large === currentItem.large ); if (newIndex !== -1) { this.gallery.index = newIndex; } } // Update navigation buttons this.updateNavigationButtons(); } /** * Ensure gallery is accessible */ setupGalleryAccessibility() { // Add ARIA attributes this.gallery.modal.setAttribute('aria-modal', 'true'); this.gallery.modal.setAttribute('aria-label', 'Image Gallery'); } hideBody(){ document.body.style.overflow = 'hidden'; } showBody(){ document.body.style.overflow = ''; } } // Initialize feed blocks when DOM is loaded document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.feed-block').forEach(container => { // Initialize with both the container and overlay window.feedBlock = new FeedBlock(container); }); }); function isEmptyObject(obj) { return Object.keys(obj).length === 0; }