class FeedBlock { constructor() { this.container = document.querySelector('section.feed-block'); if (!this.container) { return; } this.a11y = window.jvbA11y; this.cache = new window.jvbCache('feed'); this.error = window.jvbError; this.config = { source: '', context: '', highlight: null, gallery: false, view: this.cache.get('feedView') || 'grid', ... this.container.dataset }; this.initElements(); this.initFilters(); this.loadWhenAble(); } loadWhenAble() { if ('requestIdleCallback' in window) { requestIdleCallback(() => { this.initTaxonomies(); this.initStore(); this.initListeners(); this.initGallery(); }, { timeout: 2000 }); } else { setTimeout(() => { this.initTaxonomies(); this.initStore(); this.initListeners(); this.initGallery(); }, 100); } } initElements() { this.currentTaxonomies = new Set(); // Allowed Taxonomies, grabbed from active buttons this.taxonomyFilters = {}; this.elements = { filterTrigger: '[data-filter]', filters: { content: '[data-filter="content"]', orderby: '[data-filter="orderby"]', order: '[data-filter="order"]', match: '[data-filter="match"]', favourites: '[data-filter="favourites"]', taxonomy: '[data-filter^="taxonomy"]' }, selectedTax: '.selected-items', clearFilter: 'button.clear-filters', loadMore: 'button.load-more', filterContainer: '.filters', grid: '.item-grid', }; this.ui = window.uiFromSelectors(this.elements); this.ui.content = this.ui.filterContainer.querySelectorAll('[name="content"]')??false; this.ui.taxonomies = this.ui.filterContainer.querySelectorAll('[data-taxonomy]'); if (this.ui.content && this.ui.content.length > 0) { this.contentTypes = Array.from( this.ui.content ).map(content => content.value); } else { this.contentTypes = [this.container.dataset['content']]; } if (this.ui.taxonomies.length>0) { this.taxonomies = Array.from( this.ui.taxonomies, ).map(content => content.dataset.taxonomy); } else { this.taxonomies = []; } } async initTaxonomies() { this.selector = window.jvbSelector; const buttons = document.querySelectorAll('[data-filter="taxonomy"]'); this.selector.isInitializing = true; buttons.forEach((button) => { const taxonomy = button.dataset.taxonomy; this.currentTaxonomies.add(taxonomy); this.selector.registerFilterButton(button, { button: button, buttonSelector: '[data-filter="taxonomy"]', selected: this.ui.selectedTax }); // Add preload listeners this.addTaxonomyPreloadListeners(button, taxonomy); }); this.selector.isInitializing = false; this.selector.subscribe((event, data) => { if (event === 'selected-terms') this.handleTaxonomyChange(data); }); } addTaxonomyPreloadListeners(button, taxonomy) { const preload = () => { this.selector.preloadTaxonomy(taxonomy); }; // Desktop hover button.addEventListener('mouseenter', preload, { once: true }); // Touch/keyboard (fires before click) button.addEventListener('pointerdown', preload, { once: true }); // Keyboard focus button.addEventListener('focus', preload, { once: true }); } handleTaxonomyChange(data) { const { terms, taxonomy } = data; // Update only the current taxonomy's terms if (terms.size > 0) { this.taxonomyFilters[taxonomy] = Array.from(terms.keys()); } else { // Remove taxonomy if no terms selected delete this.taxonomyFilters[taxonomy]; } // Build filters object with all taxonomies let filters = { page: 1 }; // Add taxonomy filters if any exist if (Object.keys(this.taxonomyFilters).length > 0) { filters.taxonomy = this.taxonomyFilters; } this.updateFilter(filters); } clearAllTaxonomies() { this.taxonomyFilters = {}; window.removeChildren(this.ui.selectedTax); this.updateFilter({ taxonomy: null, page: 1 }); } initFilters() { //defaults this.filters = { content: this.contentTypes[0], orderby: 'date', order: 'desc', page: 1 }; if (this.config.context) this.filters.context = this.config.context; if (this.config.source) this.filters.source = this.config.source; //check the cache this.processCachedFilters(); //check url this.processURLFilters(); // Set initial UI state this.syncUIToFilters(); } syncUIToFilters() { if (this.ui.filterContainer) { // Check radio buttons Object.entries(this.filters).forEach(([key, value]) => { const input = this.ui.filterContainer.querySelector(`[data-filter="${key}"][value="${value}"]`); if (input) { input.checked = true; } }); } // Update content-specific visibility this.updateContentFor(this.filters.content); } nextPage() { this.store.setFilter('page', this.store.filters.page++); } initStore() { const store = window.jvbStore.register( 'feed', { storeName: 'feed', endpoint: 'feed', keyPath: 'id', indexes: [ { name: 'content', keyPath: 'content'}, { name: 'taxonomy', keyPath: 'taxonomy'}, { name: 'user', keyPath: 'user'}, { name: 'date', keyPath: 'modified'}, { name: 'title', keyPath: 'title'} ], filters: this.filters, TTL: 6 * 60 * 60 * 1000, showLoading: true, required: 'content', delayFetch: true } ); this.store = store.feed; this.store.subscribe((event, data) => { switch (event) { case 'data-loaded': this.renderItems(); this.ui.loadMore.hidden = true; if (this.store.lastResponse && this.store.lastResponse['has_more']) { this.ui.loadMore.hidden = !this.store.lastResponse['has_more']; } break; } }); } initGallery() { this.gallery = (this.config.gallery) ? window.jvbGallery : false; if (this.gallery) { this.gallery.subscribe((event, data) => { if (event === 'load-more' && this.store.lastResponse) { if (this.store.lastResponse['has_more']) { this.nextPage(); } } }); } } processCachedFilters() { Object.keys(this.filters).forEach(filter => { let cached = this.cache.get(`${this.config.source}_${this.config.context}_${filter}`); if (cached && cached !== this.filters[filter]){ this.filters[filter] = cached; } }); } processURLFilters() { if (this.filters.page > 1) { return false; } const params = new URLSearchParams(window.location.search); if (!params.toString()) { return false; } let filters = ['content', 'order', 'orderby', 'favourites', 'match']; filters.forEach(filter => { let value = params.get(`f_${filter}`); if (value) { this.filters[filter] = value; let input = this.ui.filters[filter]; if (input) { input.checked = true; } } }); let hasTaxonomy = false; // Load taxonomy filters from URL params.forEach((value, key) => { if (key.startsWith('f_tax_')) { hasTaxonomy = true; const taxonomy = key.replace('f_tax_', ''); if (!this.taxonomyFilters[taxonomy]) { this.taxonomyFilters[taxonomy] = []; } this.taxonomyFilters[taxonomy] = value.split(',').map(Number); } }); if (this.ui.filterContainer && hasTaxonomy) { for (let [tax, ids] in Object.entries(this.taxonomyFilters)) { let button = this.ui.filterContainer.querySelector(`[data-taxonomy="${tax}"]`); if (button) { if (button.dataset.fieldId) { let field = this.selector.get(button.dataset.fieldId); field.selectedTerms = new Set(ids); this.selector.initFieldDisplay(button.dataset.fieldId); } else { this.selector.registerField(button, { button: button, buttonSelector: '[data-filter="taxonomy"]', selected: this.ui.selectedTax, selectedItems: ids }); } } } } return true; } /** * Update URL with current filters (for sharing/bookmarking) */ updateURL() { const params = new URLSearchParams(); // Add simple filters ['content', 'order', 'orderby', 'match'].forEach(key => { if (this.filters[key]) { params.set(`f_${key}`, this.filters[key]); } }); // Add taxonomy filters Object.entries(this.taxonomyFilters).forEach(([taxonomy, terms]) => { if (terms.length > 0) { params.set(`f_tax_${taxonomy}`, terms.join(',')); } }); // Update URL without reload const newURL = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`; window.history.pushState({ filters: this.filters }, '', newURL); } renderItems() { let items = this.store.getFiltered(); if (this.store.filters['page'] === 1) { window.removeChildren(this.ui.grid); } if (items.length === 0) { this.a11y.announceItems(0, this.store.filters['page'] > 0); return; } 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); } if (endIndex < items.length) { requestAnimationFrame(() => processBatch(endIndex)); } else { this.removePlaceholders(); this.ui.grid.append(fragment); if (this.config.gallery) { this.gallery.updateGalleryItems(this.gallery.getGalleryItems()); } this.a11y.makeNavigable(this.ui.grid.querySelectorAll('.item:not([data-keyboard-nav])')); this.a11y.announceItems(items.length, this.store.filters['page'] > 1, this.store.hasMore); } }; if (items.length > 0) { processBatch(0); } else { this.a11y.announceItems(0, this.store.filters['page'] >1, false); } if (this.ui.filters.match) { this.ui.filters.match.hidden = Object.keys(this.taxonomyFilters).length === 0; } if (this.ui.clearFilter) { this.ui.clearFilter.hidden = Object.keys(this.taxonomyFilters).length === 0; } } /** * * @param {object} item */ createItemElement(item) { let template = window.getTemplate(`feedItem${window.uppercaseFirst(item.content)}`); const isTimeline = Object.hasOwn(template.dataset, 'timeline'); // Format fields using helpers for (let [fieldName, value] of Object.entries(item.fields)) { if (isTimeline && ['timeline', 'number'].includes(fieldName)) continue; let el = template.querySelector(`[data-field="${fieldName}"]`); if (!el) continue; if (value === '') { el.remove(); continue; } if (this.isImageField(item, value)) { this.formatImageFields(el, value, item); } else if (this.isTaxonomyField(item, fieldName)) { this.formatTaxonomyField(el, item, fieldName, value); } else if (this.isTimeField(el)) { this.formatTimeField(el, value); } else { this.formatField(el, value); } } // Handle link let link = template.querySelector('a'); if (link && item.url !== '') { [ link.href, link.title ] = [ item.url, `View ${item.fields['post_title']??'Item'}` ]; } if (isTimeline) { this.addTimelineElements(item, template); } return template; } splitIDs(value) { return String(value).split(',').map((value) => parseInt(value.trim())).filter(value=>value); } isImageField(item, value) { if (!Object.hasOwn(item, 'images') || Object.keys(item.images).length === 0) { return false; } let values = this.splitIDs(value); return values.some(v => Object.keys(item.images).map(k => parseInt(k)).includes(parseInt(v)) ); } formatImageFields(element, value, item) { let values = this.splitIDs(value); // Convert string to array first if (values.length === 0) return; if (values.length > 1) { let image = element.querySelector('img'); if (!image) return; values.forEach(imgID => { let img = image.cloneNode(true); this.formatImageField(img, imgID, item); element.append(img); }); image.remove(); } else { if (element.tagName !== 'IMG') { element = element.querySelector('img'); if (!element) return; } this.formatImageField(element, values[0], item); } } formatImageField(element, value, item) { let imgData = item.images[value]??false; if (!imgData) return; [ element.src, element.srcset, element.alt ] = [ imgData.tiny, `${imgData.tiny} 50w, ${imgData.small} 300w, ${imgData.medium} 1024w`, imgData['image-alt-text'] ] } isTaxonomyField(item, field) { if (!Object.hasOwn(item, 'taxonomies') || Object.keys(item.taxonomies).length === 0) { return false; } return Object.keys(item.taxonomies).includes(field); } formatTaxonomyField(element, item, field, value) { if (element.tagName !== 'UL' || !element.querySelector('li')) return; let values = this.splitIDs(value); if (values.length === 0) { element.remove(); } let listItem = element.querySelector('li'); for (let termID of values) { let term = item.taxonomies[field][termID]??false; if (!term) continue; let termItem = listItem.cloneNode(true); let link = termItem.querySelector('a'); if (!link) continue; [ link.href, link.title, link.textContent ] = [ term.url, `See more ${term.title}`, term.title ]; element.append(termItem); } listItem.remove(); } isTimeField(el) { return el.tagName === 'TIME' || el.querySelector('time') !== null; } formatTimeField(element, value) { if (element.tagName !== 'TIME') { element = element.querySelector('time'); if (!element) return; } element.setAttribute('datetime', value); element.textContent = window.formatTimeAgo(value, 'F Y'); } formatField(element, value) { element.textContent = value; } addTimelineElements(item, template) { let [ afterEl, number, started, last ] = [ template.querySelector('span.after-text'), template.querySelector('[data-field="number"] b'), template.querySelector('[data-field="started"] time'), template.querySelector('[data-field="updated"] time') ]; if (afterEl) { afterEl.textContent = `After ${item.fields.number} Tx`; } if (number) { number.textContent = item.fields.number; } if (started) { this.formatTimeField(started, item.fields.timeline[0]['post_date']); } if (last) { this.formatTimeField(last, item.fields.timeline[item.fields.timeline.length - 1]['post_date']); } } removePlaceholders() { const placeholders = this.ui.grid.querySelectorAll('.placeholder'); if (placeholders.length > 0) { placeholders.forEach(p => p.remove()); } } addPlaceholders() { let total = this.contentTypes.length; const fragment = document.createDocumentFragment(); for (let i = 0; i < 12; i++) { let template = window.getTemplate('placeholderTemplate'); let rand = Math.floor(Math.random() * total); let icon; if (this.ui.content && this.ui.content.length > 0) { icon = this.ui.content.filter((content) => { return content.value === this.contentTypes[rand]}).querySelector('.icon').cloneNode(true); } else { icon = window.getIcon(this.container.dataset.icon); } template.append(icon); fragment.append(template); } this.ui.grid.append(fragment); } /** * * @param {object} filters {name: value} */ updateFilter(filters) { //double check filters are what we're expecting let allowed = ['taxonomy','favourites','match', ... Object.keys(this.filters)]; filters = Object.keys(filters) .filter(key => allowed.includes(key)) .reduce((obj, key) => { obj[key] = filters[key]; return obj; }, {}); if (window.getDifferences.map(this.filters, filters)) { this.filters = { ...this.filters, ...filters }; // Merge instead of replace this.updateURL(); this.store.setFilters(filters); } } /** * Update visible filters based on selected content type */ updateContentFor(contentType) { // Update taxonomy filter visibility const taxonomyButtons = this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]'); taxonomyButtons.forEach(button => { const forTypes = button.dataset.for?.split(',') || []; button.hidden = forTypes.length > 0 && !forTypes.includes(contentType); }); // Update ordering options const orderButtons = this.ui.filterContainer.querySelectorAll('[data-for]'); orderButtons.forEach(button => { const forTypes = button.dataset.for?.split(',') || []; if (forTypes.length > 0) { button.hidden = !forTypes.includes(contentType); // Uncheck if hiding if (button.hidden && button.checked) { button.checked = false; } } }); // Update order direction visibility based on selected orderby const orderBy = this.ui.filterContainer.querySelector('[name="orderby"]:checked'); this.updateOrderDirectionVisibility(orderBy?.value); } /** * Show/hide order direction based on orderby selection */ updateOrderDirectionVisibility(orderBy) { const orderDirection = this.ui.filterContainer.querySelector('.order-direction'); if (orderDirection) { const forOrders = orderDirection.dataset.forOrder?.split(',') || []; orderDirection.hidden = forOrders.length > 0 && !forOrders.includes(orderBy); } } /********************************************************************* LISTENERS *********************************************************************/ initListeners() { this.popStateHandler = this.handlePopState.bind(this); this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.imageObserver = null; this.resizeObserver = null; if ('IntersectionObserver' in window) { this.imageObserver = new IntersectionObserver(entries => { entries.forEach(entry => { this.loadImage(entry.target); this.imageObserver.unobserve(entry.target); }); }, { rootMargin: '100px', threshold: .1 }); } if ('ResizeObserver' in window) { this.resizeObserver = new ResizeObserver(() => { window.debouncer.schedule( 'feed-update-images', () => this.updateImageSizes(), 250 ); }); } else { window.addEventListener('resize', () => { window.debouncer.schedule( 'feed-update-images', () => this.updateImageSizes(), 250 ); }); } window.addEventListener('popstate', this.popStateHandler); document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); } handlePopState(e) { if (e.state?.filters) { if (this.processURLFilters()) { this.store.setFilters(this.filters); this.a11y.announce('Feed filters updated from browser history'); } } } handleClick(e) { if (window.targetCheck(e, this.elements.loadMore)) { this.nextPage(); } else if (window.targetCheck(e, this.elements.clearFilter)) { this.clearAllTaxonomies(); } else if (window.targetCheck(e, '.remove-item')) { this.handleRemoveSelectedTerm(e); } } handleRemoveSelectedTerm(e) { const selectedItem = e.target.closest('.selected-item'); if (!selectedItem) return; const termId = parseInt(selectedItem.dataset.id); const taxonomy = selectedItem.dataset.taxonomy; // Remove from filters if (this.taxonomyFilters[taxonomy]) { this.taxonomyFilters[taxonomy] = this.taxonomyFilters[taxonomy] .filter(id => id !== termId); if (this.taxonomyFilters[taxonomy].length === 0) { delete this.taxonomyFilters[taxonomy]; } } // Remove from UI selectedItem.remove(); // Update filters this.updateFilter({ taxonomy: Object.keys(this.taxonomyFilters).length > 0 ? this.taxonomyFilters : null, page: 1 }); } handleChange(e) { let target = e.target; if (Object.hasOwn(target.dataset, 'filter')) { if (target.dataset.filter === 'content') { this.updateContentFor(target.value); this.updateFilter({ content: target.value, page: 1 }); } else if (target.dataset.filter === 'orderby') { this.updateOrderDirectionVisibility(target.value); this.updateFilter({ orderby: target.value, page: 1 }); } else if (target.dataset.filter === 'order') { this.updateFilter({ order: target.value, page: 1 }); } else if (target.dataset.filter === 'match') { this.updateFilter({ match: target.checked ? 'all' : 'any', page: 1 }); } else if (target.dataset.filter === 'favourites') { this.updateFilter({ favourites: target.checked, page: 1 }); } } } } document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe(event => { if (event === 'auth-loaded') { window.feedBlock = new FeedBlock(); } }); let item = { content: "art", date: "2025-12-24 03:37:26", fields: { gallery: "", post_content: "", post_thumbnail: 200, post_title: "Great Gray Owl", price: "", }, icon: "arrows-clockwise", id: 195, images: { 200: { 'image-alt-text': "", 'image-caption': "", 'image-title': "Great Gray Owl", large: "http://jakevan.test/wp-content/uploads/2025/12/Great-Gray-Owl.jpg", medium: "http://jakevan.test/wp-content/uploads/2025/12/Great-Gray-Owl-1024x1024.jpg", small: "http://jakevan.test/wp-content/uploads/2025/12/Great-Gray-Owl-300x300.jpg", tiny: "http://jakevan.test/wp-content/uploads/2025/12/Great-Gray-Owl-50x50.jpg" } }, url: "http://jakevan.test/art/great-gray-owl/", user_id: 3 }; });