class FeedBlock { constructor() { this.cache = window.jvbCache; this.a11y = window.jvbA11y; this.loading = window.jvbLoading; this.error = window.jvbError; this.container = document.querySelector('section.feed-block'); if (!this.container) { return; } this.openGallery = false; this.initElements(); this.addPlaceholders(); this.config = { api: feedSettings.apiUrl, nonce: feedSettings.nonce, user: jvbSettings.currentUser || null, source: '', context: '', highlight: null, gallery: false, showAuthor: true, showDate: false, view: localStorage.getItem('feedViewMode') || 'grid', ... this.container.dataset }; this.taxonomies = {}; this.rendered = {}; this.feed = { imageLoadThreshold: 5, lazyLoadOffset: '100px', gallery: [], loaded: 0, intsersectionObserver: null, templates: new Map() }; this.isLoading = false; this.hasMore = true; this.retries = { count: 0, max: 3, delay: 1000 }; this.page = 1; this.order = 'DESC'; this.orderby = 'date'; this.gallery = (this.config.gallery) ? new window.jvbGallery(document.querySelector('dialog.gallery'), { imageWrapper: '.item', loadMore: ()=>this.fetchFeed.bind(this) }) : false; this.initListeners(); if (this.page === 1) { this.processURLFilters(); } else { this.updateFilters(); } } initElements() { this.filterSelector = 'form.feed-filters'; this.filterForm = this.container.querySelector(this.filterSelector); this.grid = this.container.querySelector('.item-grid'); this.loadMore = this.container.querySelector('.load-more'); this.filterControls = this.container.querySelector('.filter-actions'); this.contentTypes = Array.from(this.filterForm.querySelectorAll('input[name="content"]')).map( content => { return content.value; }); this.selectedTerms = this.container.querySelector('.selected-items-section .selected-items'); } initListeners() { window.addEventListener('popstate', this.handlePopState.bind(this)); document.addEventListener('click', this.handleClick.bind(this)); document.addEventListener('change', this.handleChange.bind(this)); // 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: '100px', 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)); } this.taxonomies = {}; this.container.querySelectorAll('.jvb-selector:not([hidden])').forEach(selector => { let taxonomy = selector.dataset.taxonomy; if (!Object.hasOwn(this.taxonomies, taxonomy)) { this.taxonomies[taxonomy] = new window.jvbTaxonomySelector( selector, { multiple: true, feed: true, selected: {}, onClose: () => this.setSelectedTerms(taxonomy), } ); } }); } /** * 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); //No parameters to process if (!params.toString()) { this.updateFilters(); return; } let filters = ['content', 'order', 'orderby', 'favourites','match']; filters.forEach(filter => { let value = params.get('f_'+filter); params.delete('f_'+filter); if (value && this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`)) { this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`).checked = true; } }); let unprocessed = {}; for (var [key, value] of Object.entries(Object.fromEntries(params))) { key = key.replace('f_',''); if (this.contentTypes.includes(key)) { this.openGallery = value; } else { this.taxonomies[key].addTermsFromURL(value); this.setSelectedTerms(key); } } this.updateFilters(); } handleClick(e) { if (e.target.classList.contains('load-more') || e.target.closest('.load-more')) { this.fetchFeed(false); e.target.disabled = true; } else if (e.target.classList.contains('clear-filters') || e.target.closest('.clear-filters')) { this.resetFilters(); } else if (this.config.gallery && e.target.closest('.feed-image')) { this.gallery.handleGalleryOpen(e); } else if (e.target.classList.contains('.remove-item') || e.target.closest('.remove-item')) { let tag = e.target.closest('.selected-item'); let taxonomy = tag.dataset.taxonomy; this.taxonomies[taxonomy].removeSelectedTerm(tag.dataset.id); this.setSelectedTerms(taxonomy); this.updateFilters(); } } handleChange(e) { if (e.target.closest(this.filterSelector)) { this.resetPage(); window.removeChildren(this.grid); this.addPlaceholders(); //update filters this.updateFilters(); } } updateFilters() { this.page = 1; const params = new URLSearchParams(window.location.search); let filters = Object.fromEntries(new FormData(this.filterForm)); let contents = []; for (let [key, value] of Object.entries(filters)) { let set = false; switch (key) { case 'content': if (value !== this.contentTypes[0]) { set = true; } else { params.delete('f_'+key); } break; case 'orderby': if (value !== 'date') { set = true; } break; case 'order': if (value !== 'desc') { set = true; } break; default: set = true; } if (!set) { params.delete('f_'+key); } if (set && value !== false && value !== '') { params.set('f_'+key, value); } if (value !== '') { contents.push(value); } const newURL = `${window.location.pathname}?${params.toString()}`; history.pushState(filters, '', newURL); } this.filters = filters; this.updateContentFor(filters.content); this.updateFilterControls(); this.loading.setContent(contents); this.fetchFeed(true); } updateFilterControls() { this.filterControls.hidden = this.selectedTerms.children.length < 2; } /** * Toggles taxonomy selectors and certain order/orderby options * depending on current content * @param content */ updateContentFor(content) { this.filterForm.querySelectorAll('.jvb-selector').forEach(tax => { let hasContent = tax.dataset.for.includes(content); tax.hidden = !hasContent; if (!hasContent) { let t = tax.dataset.taxonomy; this.clearSelectedTerms(t); } }); this.filterForm.querySelectorAll('input[data-for]').forEach(toggle => { toggle.hidden = !toggle.dataset.for.includes(content); }); this.filterForm.querySelectorAll('input[name="order"]').forEach(order => { order.hidden = this.filters.order === 'random'; }); } clearSelectedTerms(taxonomy) { this.filterForm.querySelector(`input[name="${taxonomy}"]`).value = ''; if (Object.hasOwn(this.taxonomies, taxonomy)) { this.taxonomies[taxonomy].selectedItems = {}; } } setSelectedTerms(taxonomy) { let input = this.filterForm.querySelector(`input[name="${taxonomy}"]`); input.value = ''; let selected = this.taxonomies[taxonomy].selectedTerms; if (!window.isEmptyObject(selected)) { let ids = Object.keys(selected); input.value = ids.join(','); } this.updateFilters(); } nextPage() { if (this.hasMore) { this.page++; } } resetPage() { this.page = 1; this.hasMore = true; } resetState() { this.resetPage(true); this.isLoading = false; this.retries = { count: 0, max: 3, delay: 1000 }; } resetFilters() { this.filterForm.reset(); //check the first content this.filterForm.querySelector('input[name="content"]').checked = true; this.filterForm.querySelector('input[name="orderby"][value="date"]').checked = true; this.page = 1; this.updateFilters(); } buildFilterRequest() { let filters = {}; for (let [filter, value] of Object.entries(this.filters)) { if (value !== false && value !== '') { filters[filter] = value; } } filters.page = parseInt(this.page); if (this.container.dataset.context) { filters.context = this.container.dataset.context; } if (this.container.dataset.source) { filters.source = this.container.dataset.source; } return new URLSearchParams(filters).toString(); } async fetchFeed(reset = false, force = false) { if (this.isLoading) { return false; } this.loading.showLoading(this.filters); try { if (this.page === 1) { window.removeChildren(this.grid); this.addPlaceholders(); } const data = await this.cache.fetchWithCache( `${this.config.api}feed?${this.buildFilterRequest()}`, { method: 'GET', }, { context: 'feed', forceRefresh: true // forceRefresh: force } ); //Handle empty results if (!data || !data.items || data.items.length === 0) { if (this.page === 1) { this.showEmptyState(); } this.hasMore = false; return false; } else { this.hasMore = data['has_more']; this.renderItems(data.items, this.page > 1); if (this.hasMore) { this.nextPage(); } return true; } } catch (error) { this.handleError(error); } finally { this.loading.hideLoading(); if (this.openGallery !== false) { this.gallery.openWhenReady = this.openGallery; this.openGallery = false; } this.loadMore.disabled = false; this.loadMore.hidden = !this.hasMore; } } removePlaceholders() { if (this.grid.querySelector('.placeholder')) { window.removeChildren(this.grid); } } showEmptyState() { window.removeChildren(this.grid); let template = window.getTemplate('emptyState'); let isFavourite = Object.hasOwn(this.filters, 'favourites') && this.filters.favourites === true; if (isFavourite) { [ template.querySelector('h3').textContent, template.querySelector('p:first-of-type').textContent, template.querySelector('p:last-of-type').textContent, ] = [ '♡ 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.' ]; } this.grid.append(template); this.a11y.announceEmpty(isFavourite); } handleError(error){ return this.error.handleApiError( error, { component: 'Feed Block', action: 'loaditems' }, () => this.fetchFeed() ); } addPlaceholders() { let total = this.contentTypes.length - 1; for (let i = 0; i < 9; i++) { let template = window.getTemplate('placeholderTemplate'); let rand = Math.floor(Math.random()*total+1); let icon = window.getIcon(this.contentTypes[rand]).cloneNode(true); template.append(icon); this.grid.append(template); } } renderItems(items, append = false) { //Clear the grid if we aren't appending if (!append) { window.removeChildren(this.grid); this.addPlaceholders(); } //Bail early if no items if (items.length === 0) { this.a11y.announceUpdate(0, append); return; } //Use DocumentFragment for better performance const fragment = document.createDocumentFragment(); const batchSize = 10; const processBatch = (startIndex) => { const endIndex = Math.min(startIndex + batchSize, items.length); for (let i = startIndex; i < endIndex; i++) { const item = items[i]; const element = this.createItemElement(item); fragment.appendChild(element); this.imageObserver.observe(element); } if (endIndex < items.length) { requestAnimationFrame(() => { processBatch(endIndex); }); } else { this.removePlaceholders(); //all batches are processed, append fragment this.grid.appendChild(fragment); if (this.config.gallery) { this.gallery.updateGalleryItems(this.gallery.getGalleryItems()); } this.a11y.makeNavigable(this.grid.querySelectorAll('.item:not([data-keyboard-nav])')); this.a11y.announceItems(items.length, append, this.hasMore); } }; if (items.length > 0) { processBatch(0); } else { this.a11y.announceUpdate(0, append); } } /** * Creates a feed-item. Used by RenderItems */ 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'); template.id = `${item.icon}-${item.id}`; template.dataset.id = 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); if (!this.config.gallery) { 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); if (!this.config.gallery) { 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; } } /** * Load Image, used by renderItems * @param element */ loadImage(element) { const img = element.querySelector('img'); if (!img) return; const size = this.getImageSize(); img.src = img.dataset[size] || img.dataset.src; element.setAttribute('data-loaded', 'true'); } /** * Updates the image size according to screen size */ updateImageSizes() { const size = this.getImageSize(); const items = this.grid.querySelectorAll('.item'); items.forEach(item => { const img = item.querySelector('img'); if (img && img.dataset[size] && img.src !== img.dataset[size]) { img.src = img.dataset[size]; } }); } /** * Get image size based on screen width */ getImageSize() { const width = window.innerWidth; if (width > 1024) return 'medium'; if (width > 500) return 'medium'; return 'small'; } } document.addEventListener('DOMContentLoaded', () => { window.feedBlock = new FeedBlock(); });