| | |
| | | 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<void>} |
| | | */ |
| | | 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}<p>${quipData.quip}</p>`; |
| | | } |
| | | |
| | | /** |
| | | * 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}<p>${message}</p>`; |
| | | } |
| | | |
| | | /** |
| | | * 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') { |
| | | class FeedBlockOld { |
| | | constructor() { |
| | | this.container = document.querySelector('section.feed-block'); |
| | | if (!this.container) { |
| | | 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; |
| | | } |
| | | } |
| | | this.a11y = window.jvbA11y; |
| | | this.cache = new window.jvbCache('feed'); |
| | | this.error = window.jvbError; |
| | | |
| | | /** |
| | | * Show empty state |
| | | */ |
| | | showEmptyState() { |
| | | const message = this.filters.favourites |
| | | ? `<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.config = { |
| | | source: '', |
| | | context: '', |
| | | highlight: null, |
| | | gallery: false, |
| | | view: this.cache.get('feedView') || 'grid', |
| | | ... this.container.dataset |
| | | }; |
| | | this.initElements(); |
| | | this.initFilters(); |
| | | |
| | | 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; |
| | | } |
| | | this.loadWhenAble(); |
| | | } |
| | | |
| | | /** |
| | | * Get image size based on screen width |
| | | */ |
| | | getImageSize() { |
| | | const width = window.innerWidth; |
| | | if (width > 1024) return 'medium'; |
| | | if (width > 500) return 'medium'; |
| | | return 'small'; |
| | | } |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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; |
| | | 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); |
| | | |
| | | 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; |
| | | 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']]; |
| | | } |
| | | |
| | | const boundHandler = handler.bind(this); |
| | | element.addEventListener(event, boundHandler, options); |
| | | if (this.ui.taxonomies.length>0) { |
| | | this.taxonomies = Array.from( |
| | | this.ui.taxonomies, |
| | | ).map(content => content.dataset.taxonomy); |
| | | } else { |
| | | this.taxonomies = []; |
| | | } |
| | | |
| | | // 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(); |
| | | async initTaxonomies() { |
| | | this.selector = window.jvbSelector; |
| | | const buttons = document.querySelectorAll('[data-filter="taxonomy"]'); |
| | | |
| | | // Clean up timers |
| | | if (this.quipInterval) { |
| | | clearInterval(this.quipInterval); |
| | | } |
| | | this.selector.isInitializing = true; |
| | | buttons.forEach((button) => { |
| | | const taxonomy = button.dataset.taxonomy; |
| | | this.currentTaxonomies.add(taxonomy); |
| | | |
| | | if (this.timeoutId) { |
| | | clearTimeout(this.timeoutId); |
| | | } |
| | | this.selector.registerFilterButton(button, { |
| | | button: button, |
| | | buttonSelector: '[data-filter="taxonomy"]', |
| | | selected: this.ui.selectedTax |
| | | }); |
| | | |
| | | // Clear template cache and other state |
| | | this.feed.templates.clear(); |
| | | this.feed.gallery = []; |
| | | this.feed.loaded = 0; |
| | | } |
| | | /** Extra Term Handling **/ |
| | | // Add preload listeners |
| | | this.addTaxonomyPreloadListeners(button, taxonomy); |
| | | }); |
| | | |
| | | 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}"]` |
| | | ); |
| | | this.selector.isInitializing = false; |
| | | |
| | | return Array.from(selectedItems).map(item => item.dataset.id); |
| | | } |
| | | this.selector.subscribe((event, data) => { |
| | | if (event === 'selected-terms') this.handleTaxonomyChange(data); |
| | | }); |
| | | } |
| | | |
| | | clearSelectedTaxonomies(){ |
| | | window.removeChildren(this.elements.selected); |
| | | if(!isEmptyObject(this.selectorInstances)){ |
| | | for(var [taxonomy, instance] of Object.entries(this.selectorInstances)){ |
| | | instance.selectedItems = {}; |
| | | } |
| | | } |
| | | addTaxonomyPreloadListeners(button, taxonomy) { |
| | | const preload = () => { |
| | | this.selector.preloadTaxonomy(taxonomy); |
| | | }; |
| | | |
| | | this.elements.matchAll.querySelector(input).checked = false; |
| | | // Desktop hover |
| | | button.addEventListener('mouseenter', preload, { once: true }); |
| | | |
| | | this.filters.taxonomies = {}; |
| | | this.updateFilters(); |
| | | } |
| | | setSelectedTerms(taxonomy){ |
| | | if(this.selectorInstances[taxonomy]){ |
| | | let selected = this.selectorInstances[taxonomy].selectedItems; |
| | | if(!isEmptyObject(selected)){ |
| | | // Touch/keyboard (fires before click) |
| | | button.addEventListener('pointerdown', preload, { once: true }); |
| | | |
| | | 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]; |
| | | } |
| | | // Keyboard focus |
| | | button.addEventListener('focus', preload, { once: true }); |
| | | } |
| | | |
| | | } |
| | | } |
| | | 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){ |
| | | handleTaxonomyChange(data) { |
| | | const { terms, taxonomy } = data; |
| | | |
| | | 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 = {}; |
| | | } |
| | | // 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 |
| | | }; |
| | | |
| | | const selectedItems = this.elements.selected.querySelectorAll( |
| | | `.selected-item[data-taxonomy="${taxonomy}"]` |
| | | ); |
| | | // Add taxonomy filters if any exist |
| | | if (Object.keys(this.taxonomyFilters).length > 0) { |
| | | filters.taxonomy = this.taxonomyFilters; |
| | | } |
| | | |
| | | if(selectedItems.length > 0){ |
| | | selectedItems.forEach(item => { |
| | | item.remove(); |
| | | }); |
| | | } |
| | | this.updateFilter(filters); |
| | | } |
| | | |
| | | // Update clear filters button visibility |
| | | this.updateClearFiltersButton(); |
| | | } |
| | | updateClearFiltersButton(){ |
| | | if (!this.elements.clearFilters) return; |
| | | clearAllTaxonomies() { |
| | | this.taxonomyFilters = {}; |
| | | window.removeChildren(this.ui.selectedTax); |
| | | |
| | | let filters = this.elements.selected.children.length; |
| | | this.updateFilter({ |
| | | taxonomy: null, |
| | | page: 1 |
| | | }); |
| | | } |
| | | |
| | | const hasFilters = filters > 0; |
| | | 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; |
| | | |
| | | const hasMultiple = filters > 1; |
| | | //check the cache |
| | | this.processCachedFilters(); |
| | | //check url |
| | | this.processURLFilters(); |
| | | |
| | | this.elements.clearFilters.hidden = !hasFilters; |
| | | // 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; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | this.elements.filters.classList.toggle('has-filters', hasFilters); |
| | | // Update content-specific visibility |
| | | this.updateContentFor(this.filters.content); |
| | | } |
| | | nextPage() { |
| | | this.store.setFilter('page', this.store.filters.page++); |
| | | } |
| | | |
| | | this.elements.matchAll.hidden = !hasMultiple; |
| | | } |
| | | 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; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Create a filter tag element |
| | | */ |
| | | createFilterTag(taxonomy, id, name) { |
| | | const tag = window.getTemplate('selectedTerm'); |
| | | 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(); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | } |
| | | |
| | | 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)}`]; |
| | | 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; |
| | | } |
| | | } |
| | | }); |
| | | |
| | | return tag; |
| | | } |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | | * Gallery |
| | | **/ |
| | | openGallery(index){ |
| | | this.gallery.index = index; |
| | | this.gallery.modal.showModal(); |
| | | this.hideBody(); |
| | | /** |
| | | * Update URL with current filters (for sharing/bookmarking) |
| | | */ |
| | | updateURL() { |
| | | const params = new URLSearchParams(); |
| | | |
| | | this.bindGalleryEvents(); |
| | | //show current image |
| | | this.updateDisplay(index); |
| | | //preload adjacent images |
| | | this.preloadImages(); |
| | | // Add simple filters |
| | | ['content', 'order', 'orderby', 'match'].forEach(key => { |
| | | if (this.filters[key]) { |
| | | params.set(`f_${key}`, this.filters[key]); |
| | | } |
| | | }); |
| | | |
| | | // 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); |
| | | // 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); |
| | | } |
| | | |
| | | /** |
| | | * 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'); |
| | | renderItems() { |
| | | let items = this.store.getFiltered(); |
| | | if (this.store.filters['page'] === 1) { |
| | | window.removeChildren(this.ui.grid); |
| | | } |
| | | |
| | | modal.innerHTML = ` |
| | | <button class="gallery-close" aria-label="Close gallery"> |
| | | ${jvbSettings.icons.close} |
| | | </button> |
| | | if (items.length === 0) { |
| | | this.a11y.announceItems(0, this.store.filters['page'] > 0); |
| | | return; |
| | | } |
| | | |
| | | <button class="gallery-nav gallery-prev" aria-label="Previous image"> |
| | | ${jvbSettings.icons.prev} |
| | | </button> |
| | | const fragment = document.createDocumentFragment(); |
| | | const batchSize = 10; |
| | | |
| | | <button class="gallery-nav gallery-next" aria-label="Next image"> |
| | | ${jvbSettings.icons.next} |
| | | </button> |
| | | const processBatch = (startIndex) => { |
| | | const endIndex = Math.min(startIndex + batchSize, items.length); |
| | | |
| | | <div class="gallery-content"> |
| | | <img src="" alt="" class="gallery-image"> |
| | | <details> |
| | | <summary>DETAILS</summary> |
| | | <div class="item-info"></div> |
| | | </details> |
| | | </div> |
| | | for (let i = startIndex; i < endIndex; i++) { |
| | | const item = items[i]; |
| | | const element = this.createItemElement(item); |
| | | |
| | | fragment.appendChild(element); |
| | | } |
| | | |
| | | <div class="gallery-favourite"></div> |
| | | <div class="gallery-counter"><span id="gallery-index">1</span> / <span class="total"></span></div> |
| | | `; |
| | | if (endIndex < items.length) { |
| | | requestAnimationFrame(() => processBatch(endIndex)); |
| | | } else { |
| | | this.removePlaceholders(); |
| | | this.ui.grid.append(fragment); |
| | | |
| | | return modal; |
| | | } |
| | | 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); |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * Bind event handlers |
| | | */ |
| | | bindGalleryEvents() { |
| | | // Close button |
| | | this.gallery.modal.querySelector('.gallery-close').addEventListener('click', () => this.closeGallery()); |
| | | if (items.length > 0) { |
| | | processBatch(0); |
| | | } else { |
| | | this.a11y.announceItems(0, this.store.filters['page'] >1, false); |
| | | } |
| | | |
| | | // Navigation buttons |
| | | const prevBtn = this.gallery.modal.querySelector('.gallery-prev'); |
| | | const nextBtn = this.gallery.modal.querySelector('.gallery-next'); |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | prevBtn.addEventListener('click', () => this.navigate(-1)); |
| | | nextBtn.addEventListener('click', () => this.navigate(1)); |
| | | /** |
| | | * |
| | | * @param {object} item |
| | | */ |
| | | createItemElement(item) { |
| | | let template = window.getTemplate(`feedItem${window.uppercaseFirst(item.content)}`); |
| | | |
| | | // 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); |
| | | const isTimeline = Object.hasOwn(template.dataset, 'timeline'); |
| | | |
| | | // Touch events |
| | | this.gallery.modal.addEventListener('touchstart', (e) => { |
| | | this.gallery.touchStart = e.touches[0].clientX; |
| | | }); |
| | | // 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; |
| | | |
| | | this.gallery.modal.addEventListener('touchmove', (e) => { |
| | | this.gallery.touchEnd = e.touches[0].clientX; |
| | | }); |
| | | if (value === '') { |
| | | el.remove(); |
| | | continue; |
| | | } |
| | | |
| | | this.gallery.modal.addEventListener('touchend', () => { |
| | | if (!this.gallery.touchStart || !this.gallery.touchEnd) return; |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | const distance = this.gallery.touchStart - this.gallery.touchEnd; |
| | | const isLeftSwipe = distance > this.gallery.minSwipe; |
| | | const isRightSwipe = distance < -this.gallery.minSwipe; |
| | | // Handle link |
| | | let link = template.querySelector('a'); |
| | | if (link && item.url !== '') { |
| | | [ |
| | | link.href, |
| | | link.title |
| | | ] = [ |
| | | item.url, |
| | | `View ${item.fields['post_title']??'Item'}` |
| | | ]; |
| | | } |
| | | |
| | | if (isLeftSwipe) { |
| | | this.navigate(1); |
| | | } else if (isRightSwipe) { |
| | | this.navigate(-1); |
| | | } |
| | | if (isTimeline) { |
| | | this.addTimelineElements(item, template); |
| | | } |
| | | |
| | | this.gallery.touchStart = null; |
| | | this.gallery.touchEnd = null; |
| | | }); |
| | | } |
| | | 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); |
| | | |
| | | /** |
| | | * Navigate to previous/next image |
| | | */ |
| | | async navigate(direction) { |
| | | const newIndex = this.gallery.index + direction; |
| | | 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; |
| | | |
| | | // 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; |
| | | } |
| | | 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; |
| | | } |
| | | |
| | | // Update current index |
| | | this.gallery.index = newIndex; |
| | | 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; |
| | | |
| | | // Update display |
| | | this.updateDisplay(newIndex); |
| | | [ |
| | | 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; |
| | | } |
| | | |
| | | // Preload adjacent images |
| | | this.preloadImages(); |
| | | 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') |
| | | ]; |
| | | |
| | | // Announce to screen readers |
| | | this.a11y.announceNavigation(this.gallery.index,this.gallery.items.length); |
| | | 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']); |
| | | } |
| | | } |
| | | |
| | | //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()); |
| | | } |
| | | } |
| | | removePlaceholders() { |
| | | const placeholders = this.ui.grid.querySelectorAll('.placeholder'); |
| | | if (placeholders.length > 0) { |
| | | placeholders.forEach(p => p.remove()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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; |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | addPlaceholders() { |
| | | let total = this.contentTypes.length; |
| | | const fragment = document.createDocumentFragment(); |
| | | for (let i = 0; i < 12; i++) { |
| | | let template = window.getTemplate('placeholderTemplate'); |
| | | |
| | | /** |
| | | * Update display with current image |
| | | */ |
| | | updateDisplay(index) { |
| | | const item = this.gallery.items[index]; |
| | | if (!item) return; |
| | | 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); |
| | | } |
| | | |
| | | // 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 || ''; |
| | | /** |
| | | * |
| | | * @param {object} filters {name: value} |
| | | */ |
| | | updateFilter(filters) { |
| | | //double check filters are what we're expecting |
| | | let allowed = ['taxonomy','favourites','match', ... Object.keys(this.filters)]; |
| | | |
| | | // Update favourite button |
| | | if (favourite && item.fav) { |
| | | window.removeChildren(favourite); |
| | | favourite.appendChild(item.fav.cloneNode(true)); |
| | | } |
| | | filters = Object.keys(filters) |
| | | .filter(key => allowed.includes(key)) |
| | | .reduce((obj, key) => { |
| | | obj[key] = filters[key]; |
| | | return obj; |
| | | }, {}); |
| | | |
| | | // Update info |
| | | if (info && item.info) { |
| | | window.removeChildren(info); |
| | | const clone = item.info.cloneNode(true); |
| | | info.appendChild(clone); |
| | | } |
| | | 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 counter |
| | | counter.textContent = `${this.gallery.index + 1} / ${this.gallery.items.length}`; |
| | | // 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 navigation buttons |
| | | this.updateNavigationButtons(); |
| | | } |
| | | // Update order direction visibility based on selected orderby |
| | | const orderBy = this.ui.filterContainer.querySelector('[name="orderby"]:checked'); |
| | | this.updateOrderDirectionVisibility(orderBy?.value); |
| | | } |
| | | |
| | | /** |
| | | * Update navigation button visibility |
| | | */ |
| | | updateNavigationButtons() { |
| | | const prevBtn = this.gallery.modal.querySelector('.gallery-prev'); |
| | | const nextBtn = this.gallery.modal.querySelector('.gallery-next'); |
| | | /** |
| | | * 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 |
| | | }); |
| | | } |
| | | |
| | | prevBtn.classList.toggle('end', this.gallery.index > 0 ? '' : 'none'); |
| | | nextBtn.classList.toggle('end', this.gallery.index < this.gallery.items.length - 1 ? '' : 'none'); |
| | | } |
| | | 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 |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Close the gallery |
| | | */ |
| | | closeGallery() { |
| | | this.showBody(); |
| | | // Remove event listeners |
| | | document.removeEventListener('keydown', this.gallery.keyHandler); |
| | | this.a11y.announce('Gallery closed.'); |
| | | window.addEventListener('popstate', this.popStateHandler); |
| | | document.addEventListener('click', this.clickHandler); |
| | | document.addEventListener('change', this.changeHandler); |
| | | } |
| | | |
| | | this.gallery.modal.close(); |
| | | handlePopState(e) { |
| | | if (e.state?.filters) { |
| | | if (this.processURLFilters()) { |
| | | this.store.setFilters(this.filters); |
| | | this.a11y.announce('Feed filters updated from browser history'); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Reset state |
| | | this.gallery.keyHandler = null; |
| | | } |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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]; |
| | | handleRemoveSelectedTerm(e) { |
| | | const selectedItem = e.target.closest('.selected-item'); |
| | | if (!selectedItem) return; |
| | | |
| | | // Update items array |
| | | this.gallery.items = newItems; |
| | | const termId = parseInt(selectedItem.dataset.id); |
| | | const taxonomy = selectedItem.dataset.taxonomy; |
| | | |
| | | // 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 |
| | | ); |
| | | // Remove from filters |
| | | if (this.taxonomyFilters[taxonomy]) { |
| | | this.taxonomyFilters[taxonomy] = this.taxonomyFilters[taxonomy] |
| | | .filter(id => id !== termId); |
| | | |
| | | if (newIndex !== -1) { |
| | | this.gallery.index = newIndex; |
| | | } |
| | | } |
| | | if (this.taxonomyFilters[taxonomy].length === 0) { |
| | | delete this.taxonomyFilters[taxonomy]; |
| | | } |
| | | } |
| | | |
| | | // Update navigation buttons |
| | | this.updateNavigationButtons(); |
| | | } |
| | | // Remove from UI |
| | | selectedItem.remove(); |
| | | |
| | | /** |
| | | * Ensure gallery is accessible |
| | | */ |
| | | setupGalleryAccessibility() { |
| | | // Add ARIA attributes |
| | | this.gallery.modal.setAttribute('aria-modal', 'true'); |
| | | this.gallery.modal.setAttribute('aria-label', 'Image Gallery'); |
| | | } |
| | | // Update filters |
| | | this.updateFilter({ |
| | | taxonomy: Object.keys(this.taxonomyFilters).length > 0 |
| | | ? this.taxonomyFilters |
| | | : null, |
| | | page: 1 |
| | | }); |
| | | } |
| | | |
| | | hideBody(){ |
| | | document.body.style.overflow = 'hidden'; |
| | | } |
| | | showBody(){ |
| | | document.body.style.overflow = ''; |
| | | } |
| | | 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 }); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 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); |
| | | }); |
| | | document.addEventListener('DOMContentLoaded', async function() { |
| | | window.auth.subscribe(event => { |
| | | if (event === 'auth-loaded') { |
| | | window.feedBlock = new FeedBlock(); |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | |
| | | function isEmptyObject(obj) { |
| | | return Object.keys(obj).length === 0; |
| | | } |