Jake Vanderwerf
2026-02-10 8fc64daf7e18234717cac6c2736cb860e8ea8b97
src/feed/view.js
@@ -6,6 +6,7 @@
      this.a11y = window.jvbA11y;
      this.error = window.jvbError;
      this.cache = new window.jvbCache('feed');
      this.templates = window.jvbTemplates;
      this.config = {
         source: '',
@@ -20,6 +21,7 @@
   }
   init() {
      this.initElements();
      this.defineTemplates();
      this.initListeners();
      this.initFilters();
@@ -27,12 +29,20 @@
         requestIdleCallback(() => {
            this.initStore();
            this.initTaxonomies();
            this.processCachedFilters();
            this.processURLFilters();
            this.updateFilterUI();
            this.initGallery();
         }, { timeout: 2000 });
      } else {
         setTimeout(() => {
            this.initStore();
            this.initTaxonomies();
            this.processCachedFilters();
            this.processURLFilters();
            this.updateFilterUI();
            this.initGallery();
         }, 100);
      }
@@ -56,22 +66,28 @@
         buttons: {
            loadMore:      'button.load-more',
            remove:        '.remove-term',
            clearFilters:  'button.clear-filters'
            clearFilters:  'button.clear-filters',
            refresh:    'button[data-action="refresh"]'
         }
      };
      this.ui = window.uiFromSelectors(this.selectors, this.container);
      this.ui.buttons.refresh = document.querySelector(this.selectors.buttons.refresh);
      //Add content and taxonomies
      this.ui.content = this.ui.filters.container.querySelectorAll('[name="content"]');
      if (this.ui.content.length === 0) this.ui.content = false;
      this.ui.taxonomies = this.ui.filters.container.querySelectorAll('[data-taxonomy]');
      if (this.ui.taxonomies.length === 0) this.ui.content = false;
      if (this.ui.taxonomies.length === 0) this.ui.taxonomies = false;
      this.ui.orderbyWrap = this.ui.filters.container.querySelector('[data-for-order]');
      if (this.ui.orderbyWrap.length === 0) this.ui.content = false;
      if (this.ui.orderbyWrap.length === 0) this.ui.orderbyWrap = false;
      this.ui.order = this.ui.filters.container.querySelectorAll('[data-filter="order"]');
      if (this.ui.order.length === 0) this.ui.content = false;
      if (this.ui.order.length === 0) this.ui.order = false;
      this.ui.orderby = this.ui.filters.container.querySelectorAll('[data-filter="orderby"]');
      if (this.ui.orderby.length === 0) this.ui.content = false;
      if (this.ui.orderby.length === 0) this.ui.orderby = false;
      this.orderbyFilters = (this.ui.orderby)
         ? Array.from(this.ui.orderby).map(o => o.value)
         : [];
      this.contentTypes = (this.ui.content)
         ? Array.from(this.ui.content).map(c => c.value)
@@ -103,9 +119,6 @@
      if (this.config.source) defaults.source = this.config.source;
      this.filters = defaults;
      this.processCachedFilters();
      this.processURLFilters();
      this.updateFilterUI();
      this.defaults = {...defaults};
   }
   updateFilterUI() {
@@ -121,8 +134,8 @@
            if(group) {
               for (let input of group) {
                  let [filter, value] = [input.dataset.filter, input.value];
                  if (!Object.hasOwn(this.filters, filter)) break;
                  let doit = this.filters[filter] === value;
                  if (!Object.hasOwn(this.store.filters, filter)) break;
                  let doit = this.store.filters[filter] === value;
                  if (doit) {
                     input.checked = doit;
                     break;
@@ -131,13 +144,13 @@
            }
         });
         if (Object.hasOwn(this.filters, 'taxonomy')) {
            for (let [taxonomy, terms] of Object.entries(this.filters.taxonomy)) {
         if (Object.hasOwn(this.store.filters, 'taxonomy')) {
            for (let [taxonomy, terms] of Object.entries(this.store.filters.taxonomy)) {
               terms.forEach(termId => {
                  termId = parseInt(termId);
                  const term = this.selector.store.get(termId);
                  if (term) {
                     this.createTermElement(term);
                     this.createTermElement(termId);
                  }
               });
            }
@@ -164,6 +177,18 @@
      if (remove) {
         this.removeSelectedTerm(remove);
      }
      let refresh = window.targetCheck(e, this.selectors.buttons.refresh);
      if (refresh) {
         this.store.clearCache();
         this.store.fetch();
      }
      let orderbyButton = window.targetCheck(e, '[data-filter="orderby"]');
      if (orderbyButton && orderbyButton.value === 'random' && orderbyButton.checked) {
         // Already selected random, just re-render to trigger new shuffle
         this.renderItems();
      }
   }
   nextPage() {
@@ -204,6 +229,9 @@
         ...this.defaults,
         taxonomy: null
      });
      this.updateURL();
      this.saveToCacheFilters();
   }
   resetFilters(filters) {
@@ -213,6 +241,9 @@
         ... filters
      }
      this.store.setFilters(filters);
      this.updateURL();
      this.saveToCacheFilters();
   }
   getFieldId(taxonomy) {
@@ -315,17 +346,9 @@
      const term = this.selector.store.get(termId);
      if (!term) return;
      if (this.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
      let icon = this.getTaxonomyIcon(term.taxonomy);
      let template = window.getTemplate('feedTerm');
      if (!template) return;
      let [iconEl,span] = [template.querySelector('.icon'), template.querySelector('span')];
      if (!iconEl || !span) return;
      template.dataset.id = term.id;
      template.dataset.taxonomy = term.taxonomy;
      iconEl.className = `icon icon-${icon}`;
      span.textContent = term.name;
      this.ui.selected.append(template);
      term.icon = this.getTaxonomyIcon(term.taxonomy);
      this.ui.selected.append(this.templates.create('feedTerm', term));
   }
   processCachedFilters() {
@@ -338,31 +361,41 @@
   }
   processURLFilters() {
      if (this.filters.page > 1) return false;
      if (!this.isFirstPage()) return false;
      const params = new URLSearchParams(window.location.search);
      if (!params.toString()) return false;
      let shouldUpdate = false;
      this.allowedFilters.forEach(filter => {
         let value = params.get(`f_${filter}`);
         if (value) {
            shouldUpdate = true;
            this.filters[filter] = value;
         }
      });
      let hasTax = false;
      params.forEach((value, key) => {
         if (key.startsWith('f_tax_')) {
            hasTax = true;
            shouldUpdate = true;
            const taxonomy = key.replace('f_tax_','');
            this.taxFilters[taxonomy] = value.split(',').map(Number);
         }
      });
      if (shouldUpdate) {
         if (hasTax) {
            this.filters.taxonomy = this.taxFilters;
         }
         this.resetFilters(this.filters);
      }
      return true;
   }
   updateURL() {
      const params = new URLSearchParams();
      this.allowedFilters.forEach(key => {
         if (Object.hasOwn(this.filters, key) && this.filters[key] !== this.defaults[key]) {
            params.set(`f_${key}`, this.filters[key]);
         if (Object.hasOwn(this.store.filters, key) && this.store.filters[key] !== this.defaults[key]) {
            params.set(`f_${key}`, this.store.filters[key]);
         }
      });
@@ -371,29 +404,31 @@
            params.set(`f_tax_${tax}`, terms.join(','));
         }
      }
      const newURL = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
      if (newURL !== window.location.pathname) {
         window.history.pushState({filters:this.filters}, '', newURL)
      const currentURL = window.location.pathname + window.location.search;  // Change this line
      if (newURL !== currentURL) {
         window.history.pushState({filters:this.store.filters}, '', newURL);
      }
   }
   saveToCacheFilters() {
      // Save each filter that's changed from defaults
      Object.keys(this.filters).forEach(filter => {
         if (this.filters[filter] !== this.defaults[filter]) {
            this.cache.set(
               `${this.config.source}_${this.config.context}_${filter}`,
               this.filters[filter]
            );
      Object.keys(this.store.filters).forEach(filter => {
         const cacheKey = `${this.config.source}_${this.config.context}_${filter}`;
         if (this.store.filters[filter] !== this.defaults[filter]) {
            this.cache.set(cacheKey, this.store.filters[filter]);
         } else {
            this.cache.remove(cacheKey);
         }
      });
      // Also save taxonomy filters
      const taxCacheKey = `${this.config.source}_${this.config.context}_taxonomy`;
      if (Object.keys(this.taxFilters).length > 0) {
         this.cache.set(
            `${this.config.source}_${this.config.context}_taxonomy`,
            this.taxFilters
         );
         this.cache.set(taxCacheKey, this.taxFilters);
      } else {
         this.cache.remove(taxCacheKey);
      }
   }
@@ -408,6 +443,11 @@
      }
   }
   initStore() {
      let extraOrderby = this.orderbyFilters.filter(v => !['date','modified','title','random'].includes(v));
      let extraIndexes = [];
      extraOrderby.forEach(orderby =>{
         extraIndexes.push({name:orderby, keyPath: orderby});
      });
      const store = window.jvbStore.register(
         'feed',
         {
@@ -418,8 +458,10 @@
               { name: 'content', keyPath: 'content'},
               { name: 'taxonomy', keyPath: 'taxonomy'},
               { name: 'user', keyPath: 'user'},
               { name: 'date', keyPath: 'modified'},
               { name: 'title', keyPath: 'title'}
               { name: 'date', keyPath: 'date'},
               { name: 'modified', keyPath: 'modified'},
               { name: 'title', keyPath: 'title'},
               ... extraIndexes
            ],
            filters: this.filters,
            TTL: 6 * 60 * 60 * 1000, //6 hours
@@ -427,21 +469,18 @@
            required: 'content',
         }
      );
      this.store = store.feed;
      this.store.subscribe((event, data) => {
         switch (event) {
            case 'data-loaded':
               this.renderItems();
               this.renderItems(data.items);
               this.ui.buttons.loadMore.hidden = true;
               if (this.store.lastResponse && this.store.lastResponse?.has_more) {
                  this.ui.buttons.loadMore.hidden = !this.store.lastResponse?.has_more??true;
               }
               break;
            case 'filters-changed':
               this.updateURL();
               this.saveToCacheFilters();
               break;
         }
      });
   }
@@ -450,8 +489,8 @@
      return this.store.filters.page === 1;
   }
   renderItems() {
      let items = this.store.getFiltered();
   renderItems(items = null) {
      items = items??this.store.getFiltered();
      if (this.isFirstPage()) {
         window.removeChildren(this.ui.grid);
      }
@@ -459,26 +498,18 @@
         this.showEmptyState();
         this.a11y.announceItems(0, this.isFirstPage());
      } else {
         const fragment = document.createDocumentFragment();
         const processBatch = (startIndex) => {
            const endIndex = Math.min(startIndex + 10, items.length);
            for (let i = startIndex; i < endIndex; i++) {
               const item = items[i];
               const element = this.createItemElement(item);
               fragment.append(element);
            }
            if (endIndex < items.length) {
               requestAnimationFrame(() => processBatch(endIndex));
            } else {
         window.chunkIt(
            items,
            (item) => this.createItemElement(item),
            (fragment) => {
               this.removePlaceholders();
               this.ui.grid.append(fragment);
               if (this.config.gallery) this.gallery.buildGalleryItems('.item img');
               this.a11y.makeNavigable(this.ui.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
               this.a11y.announceItems(items.length, !this.isFirstPage(), this.store.lastResponse?.has_more??false);
            }
         };
         processBatch(0);
            },
            5
         ).then(()=>{});
      }
      this.updateFilterControls();
@@ -486,48 +517,15 @@
   showEmptyState() {
      window.removeChildren(this.ui.grid);
      let template = window.getTemplate('emptyState');
      if (!template) return;
      this.ui.grid.append(template);
      this.ui.grid.append(this.templates.create('emptyState'));
   }
   createItemElement(item) {
      let template = window.getTemplate(`feedItem${window.uppercaseFirst(item.content)}`);
      const isTimeline = Object.hasOwn(template.dataset, 'timeline');
      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.formatImageField(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);
         }
      if (typeof item !== 'object') {
         item = this.store.get(item);
         if (!item) return;
      }
      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;
      return this.templates.create(`feedItem${window.uppercaseFirst(item.content)}`, item);
   }
   splitIDs(value) {
      return String(value).split(',').map((value) => parseInt(value.trim())).filter(value=>value);
@@ -640,10 +638,10 @@
      ];
      if (afterEl) {
         afterEl.textContent = `After ${item.fields.number} Tx`;
         afterEl.textContent = `After ${item.number - 1} Tx`;
      }
      if (number) {
         number.textContent = item.fields.number;
         number.textContent = item.number - 1;
      }
      if (started) {
         this.formatTimeField(started, item.fields.timeline[0]['post_date']);
@@ -660,6 +658,64 @@
      }
   }
   defineTemplates() {
      const T = this.templates;
      const f = this;
      T.define('feedTerm', {
         refs: {
            icon: '.icon',
            span: 'span'
         },
         setup({el, refs, manyRefs, data}) {
            el.dataset.id = data.id;
            el.dataset.taxonomy = data.taxonomy;
            if (refs.icon) refs.icon.className = `icon icon=${data.icon}`;
            if (refs.span) refs.span.textContent = data.name;
         }
      });
      T.define('emptyState');
      this.contentTypes.forEach(content => {
         T.define(`feedItem${window.uppercaseFirst(content)}`, {
            refs: {
               link: 'a',
            },
            manyRefs: {
               fields: '[data-field]',
            },
            setup({el, refs, manyRefs, data}) {
               const isTimeline = Object.hasOwn(el.dataset, 'timeline');
               if (manyRefs.fields) {
                  for (let field of manyRefs.fields) {
                     if (isTimeline && ['timeline','number'].includes(field.dataset.field)) continue;
                     const value = Object.hasOwn(data.fields, field.dataset.field)? data.fields[field.dataset.field] : false;
                     if (!value) {
                        field.remove();
                        continue;
                     }
                     if (f.isImageField(data, value)) {
                        f.formatImageField(field, value, data);
                     } else if (f.isTaxonomyField(data, field.dataset.field)) {
                        f.formatTaxonomyField(field, data, field.dataset.field, value);
                     } else if (f.isTimeField(field)) {
                        f.formatTimeField(field, value);
                     } else {
                        f.formatField(field, value);
                     }
                  }
                  if (refs.link && data.url !== '') {
                     refs.link.href = data.url;
                     refs.link.title = `View ${data.fields['post_title']??'Item'}`;
                  }
                  if (isTimeline ) f.addTimelineElements(data, el);
               }
            }
         })
      });
   }
   // addPlaceholders() {
   //    let total = this.contentTypes.length;
   //    const fragment = document.createDocumentFragment();