From a9b3b28d001941921aa70d37fdc87c758a163a44 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 05 Jun 2026 16:47:03 +0000
Subject: [PATCH] =Some hefty changes to FeedBlock. Transitioning to loading first page in php to save on extra requests. Got a bit to do yet, but I have to work on Northeh for a bit here.

---
 src/feed/viewOld.js | 1277 +++++++++++++++++++++++++++++----------------------------
 1 files changed, 651 insertions(+), 626 deletions(-)

diff --git a/src/feed/viewOld.js b/src/feed/viewOld.js
index 29859f9..c65be60 100644
--- a/src/feed/viewOld.js
+++ b/src/feed/viewOld.js
@@ -1,745 +1,770 @@
-class FeedBlock {
+class FeedBlockOld {
 	constructor() {
-		this.cache = window.jvbCache;
-		this.a11y = window.jvbA11y;
-		this.loading = window.jvbLoading;
-		this.error = window.jvbError;
-
-
 		this.container = document.querySelector('section.feed-block');
 		if (!this.container) {
 			return;
 		}
 
-		this.openGallery = false;
+		this.a11y = window.jvbA11y;
+		this.cache = new window.jvbCache('feed');
+		this.error = window.jvbError;
 
-		this.initElements();
-		this.addPlaceholders();
 		this.config = {
-			api: feedSettings.apiUrl,
-			nonce: feedSettings.nonce,
-			user: jvbSettings.currentUser || null,
 			source: '',
 			context: '',
 			highlight: null,
 			gallery: false,
-			showAuthor: true,
-			showDate: false,
-			view: localStorage.getItem('feedViewMode') || 'grid',
+			view: this.cache.get('feedView') || 'grid',
 			... this.container.dataset
 		};
-		this.taxonomies = {};
-		this.rendered = {};
+		this.initElements();
+		this.initFilters();
 
-		this.feed = {
-			imageLoadThreshold: 5,
-			lazyLoadOffset: '100px',
-			gallery: [],
-			loaded: 0,
-			intsersectionObserver: null,
-			templates: new Map()
-		};
 
-		this.isLoading = false;
-		this.hasMore = true;
-		this.retries = {
-			count: 0,
-			max: 3,
-			delay: 1000
-		};
-		this.page = 1;
-		this.order = 'DESC';
-		this.orderby = 'date';
-
-		this.gallery = (this.config.gallery) ? new window.jvbGallery(document.querySelector('dialog.gallery'), {
-			imageWrapper: '.item',
-			loadMore: ()=>this.fetchFeed.bind(this)
-		}) : false;
-		this.initListeners();
-		if (this.page === 1) {
-			this.processURLFilters();
-		} else {
-			this.updateFilters();
-		}
-
+		this.loadWhenAble();
 	}
+
+	loadWhenAble() {
+		if ('requestIdleCallback' in window) {
+			requestIdleCallback(() => {
+				this.initTaxonomies();
+				this.initStore();
+				this.initListeners();
+				this.initGallery();
+			}, { timeout: 2000 });
+		} else {
+			setTimeout(() => {
+				this.initTaxonomies();
+				this.initStore();
+				this.initListeners();
+				this.initGallery();
+			}, 100);
+		}
+	}
+
 	initElements() {
-		this.filterSelector = 'form.feed-filters';
-		this.filterForm = this.container.querySelector(this.filterSelector);
-		this.grid = this.container.querySelector('.item-grid');
-		this.loadMore = this.container.querySelector('.load-more');
-		this.filterControls = this.container.querySelector('.filter-actions');
-		this.contentTypes = Array.from(this.filterForm.querySelectorAll('input[name="content"]')).map(
-			content => {
-				return content.value;
-			});
-		this.selectedTerms = this.container.querySelector('.selected-items-section .selected-items');
+		this.currentTaxonomies = new Set();			// Allowed Taxonomies, grabbed from active buttons
+		this.taxonomyFilters = {};
+		this.elements = {
+			filterTrigger: '[data-filter]',
+			filters: {
+				content: '[data-filter="content"]',
+				orderby: '[data-filter="orderby"]',
+				order: '[data-filter="order"]',
+				match: '[data-filter="match"]',
+				favourites: '[data-filter="favourites"]',
+				taxonomy: '[data-filter^="taxonomy"]'
+			},
+			selectedTax: '.selected-items',
+			clearFilter: 'button.clear-filters',
+			loadMore: 'button.load-more',
+			filterContainer: '.filters',
+			grid: '.item-grid',
+		};
+		this.ui = window.uiFromSelectors(this.elements);
+
+
+		this.ui.content = this.ui.filterContainer.querySelectorAll('[name="content"]')??false;
+		this.ui.taxonomies = this.ui.filterContainer.querySelectorAll('[data-taxonomy]');
+		if (this.ui.content && this.ui.content.length > 0) {
+			this.contentTypes = Array.from(
+				this.ui.content
+			).map(content => content.value);
+		} else {
+			this.contentTypes = [this.container.dataset['content']];
+		}
+
+		if (this.ui.taxonomies.length>0) {
+			this.taxonomies = Array.from(
+				this.ui.taxonomies,
+			).map(content => content.dataset.taxonomy);
+		} else {
+			this.taxonomies = [];
+		}
+
+
 	}
 
-	initListeners() {
-		window.addEventListener('popstate', this.handlePopState.bind(this));
-		document.addEventListener('click', this.handleClick.bind(this));
-		document.addEventListener('change', this.handleChange.bind(this));
+	async initTaxonomies() {
+		this.selector = window.jvbSelector;
+		const buttons = document.querySelectorAll('[data-filter="taxonomy"]');
 
+		this.selector.isInitializing = true;
+		buttons.forEach((button) => {
+			const taxonomy = button.dataset.taxonomy;
+			this.currentTaxonomies.add(taxonomy);
 
-		// Intersection observer for lazy loading
-		if ('IntersectionObserver' in window) {
-			this.imageObserver = new IntersectionObserver(entries => {
-				entries.forEach(entry => {
-					if (entry.isIntersecting) {
-						this.loadImage(entry.target);
-						this.imageObserver.unobserve(entry.target);
-					}
-				});
-			}, {
-				rootMargin: '100px',
-				threshold: 0.1
+			this.selector.registerFilterButton(button, {
+				button: button,
+				buttonSelector: '[data-filter="taxonomy"]',
+				selected: this.ui.selectedTax
+			});
+
+			// Add preload listeners
+			this.addTaxonomyPreloadListeners(button, taxonomy);
+		});
+
+		this.selector.isInitializing = false;
+
+		this.selector.subscribe((event, data) => {
+			if (event === 'selected-terms') this.handleTaxonomyChange(data);
+		});
+	}
+
+	addTaxonomyPreloadListeners(button, taxonomy) {
+		const preload = () => {
+			this.selector.preloadTaxonomy(taxonomy);
+		};
+
+		// Desktop hover
+		button.addEventListener('mouseenter', preload, { once: true });
+
+		// Touch/keyboard (fires before click)
+		button.addEventListener('pointerdown', preload, { once: true });
+
+		// Keyboard focus
+		button.addEventListener('focus', preload, { once: true });
+	}
+
+	handleTaxonomyChange(data) {
+		const { terms, taxonomy } = data;
+
+		// Update only the current taxonomy's terms
+		if (terms.size > 0) {
+			this.taxonomyFilters[taxonomy] = Array.from(terms.keys());
+		} else {
+			// Remove taxonomy if no terms selected
+			delete this.taxonomyFilters[taxonomy];
+		}
+
+		// Build filters object with all taxonomies
+		let filters = {
+			page: 1
+		};
+
+		// Add taxonomy filters if any exist
+		if (Object.keys(this.taxonomyFilters).length > 0) {
+			filters.taxonomy = this.taxonomyFilters;
+		}
+
+		this.updateFilter(filters);
+	}
+
+	clearAllTaxonomies() {
+		this.taxonomyFilters = {};
+		window.removeChildren(this.ui.selectedTax);
+
+		this.updateFilter({
+			taxonomy: null,
+			page: 1
+		});
+	}
+
+	initFilters() {
+		//defaults
+		this.filters = {
+			content: 	this.contentTypes[0],
+			orderby: 	'date',
+			order: 		'desc',
+			page: 		1
+		};
+		if (this.config.context) this.filters.context = this.config.context;
+		if (this.config.source) this.filters.source = this.config.source;
+
+		//check the cache
+		this.processCachedFilters();
+		//check url
+		this.processURLFilters();
+
+		// Set initial UI state
+		this.syncUIToFilters();
+	}
+	syncUIToFilters() {
+		if (this.ui.filterContainer) {
+			// Check radio buttons
+			Object.entries(this.filters).forEach(([key, value]) => {
+				const input = this.ui.filterContainer.querySelector(`[data-filter="${key}"][value="${value}"]`);
+				if (input) {
+					input.checked = true;
+				}
 			});
 		}
-		// 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));
-		}
+		// Update content-specific visibility
+		this.updateContentFor(this.filters.content);
+	}
+	nextPage() {
+		this.store.setFilter('page', this.store.filters.page++);
+	}
 
-		this.taxonomies = {};
-		this.container.querySelectorAll('.jvb-selector:not([hidden])').forEach(selector => {
-			let taxonomy = selector.dataset.taxonomy;
-			if (!Object.hasOwn(this.taxonomies, taxonomy)) {
-				this.taxonomies[taxonomy] = new window.jvbTaxonomySelector(
-					selector,
-					{
-						multiple: true,
-						feed: true,
-						selected: {},
-						onClose: () => this.setSelectedTerms(taxonomy),
+	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;
 			}
 		});
 	}
 
-
-	/**
-	 * 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.');
-			}
+	initGallery() {
+		this.gallery = (this.config.gallery) ? window.jvbGallery : false;
+		if (this.gallery) {
+			this.gallery.subscribe((event, data) => {
+				if (event === 'load-more' && this.store.lastResponse) {
+					if (this.store.lastResponse['has_more']) {
+						this.nextPage();
+					}
+				}
+			});
 		}
 	}
 
+	processCachedFilters() {
+		Object.keys(this.filters).forEach(filter => {
+			let cached = this.cache.get(`${this.config.source}_${this.config.context}_${filter}`);
+			if (cached && cached !== this.filters[filter]){
+				this.filters[filter] = cached;
+			}
+		});
+	}
+
 	processURLFilters() {
-		const params = new URLSearchParams(window.location.search);
-		//No parameters to process
-		if (!params.toString()) {
-			this.updateFilters();
-			return;
+		if (this.filters.page > 1) {
+			return false;
 		}
+		const params = new URLSearchParams(window.location.search);
 
-		let filters = ['content', 'order', 'orderby', 'favourites','match'];
-
+		if (!params.toString()) {
+			return false;
+		}
+		let filters = ['content', 'order', 'orderby', 'favourites', 'match'];
 		filters.forEach(filter => {
-			let value = params.get('f_'+filter);
-			params.delete('f_'+filter);
-			if (value && this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`)) {
-				this.filterForm.querySelector(`input[name="${filter}"][value="${value}"]`).checked = true;
+			let value = params.get(`f_${filter}`);
+			if (value) {
+				this.filters[filter] = value;
+				let input = this.ui.filters[filter];
+				if (input) {
+					input.checked = true;
+				}
 			}
 		});
 
-		let unprocessed = {};
-		for (var [key, value] of Object.entries(Object.fromEntries(params))) {
-
-			key = key.replace('f_','');
-			if (this.contentTypes.includes(key)) {
-				this.openGallery = value;
-			} else {
-				this.taxonomies[key].addTermsFromURL(value);
-				this.setSelectedTerms(key);
+		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);
 			}
-		}
-
-		this.updateFilters();
-	}
-
-	handleClick(e) {
-		if (e.target.classList.contains('load-more') || e.target.closest('.load-more')) {
-			this.fetchFeed(false);
-			e.target.disabled = true;
-		} else if (e.target.classList.contains('clear-filters') || e.target.closest('.clear-filters')) {
-			this.resetFilters();
-		} else if (this.config.gallery && e.target.closest('.feed-image')) {
-			this.gallery.handleGalleryOpen(e);
-		} else if (e.target.classList.contains('.remove-item') || e.target.closest('.remove-item')) {
-			let tag = e.target.closest('.selected-item');
-			let taxonomy = tag.dataset.taxonomy;
-			this.taxonomies[taxonomy].removeSelectedTerm(tag.dataset.id);
-			this.setSelectedTerms(taxonomy);
-			this.updateFilters();
-		}
-	}
-	handleChange(e) {
-		if (e.target.closest(this.filterSelector)) {
-			this.resetPage();
-			window.removeChildren(this.grid);
-			this.addPlaceholders();
-			//update filters
-			this.updateFilters();
-		}
-	}
-
-	updateFilters() {
-		this.page = 1;
-		const params = new URLSearchParams(window.location.search);
-
-		let filters = Object.fromEntries(new FormData(this.filterForm));
-
-		let contents = [];
-		for (let [key, value] of Object.entries(filters)) {
-			let set = false;
-			switch (key) {
-				case 'content':
-					if (value !== this.contentTypes[0]) {
-						set = true;
+		});
+		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 {
-						params.delete('f_'+key);
+						this.selector.registerField(button, {
+							button: button,
+							buttonSelector: '[data-filter="taxonomy"]',
+							selected: this.ui.selectedTax,
+							selectedItems: ids
+						});
 					}
-					break;
-				case 'orderby':
-					if (value !== 'date') {
-						set = true;
-					}
-					break;
-				case 'order':
-					if (value !== 'desc') {
-						set = true;
-					}
-					break;
-				default:
-					set = true;
+				}
 			}
-			if (!set) {
-				params.delete('f_'+key);
-			}
-
-
-			if (set && value !== false && value !== '') {
-				params.set('f_'+key, value);
-			}
-			if (value !== '') {
-				contents.push(value);
-			}
-
-			const newURL = `${window.location.pathname}?${params.toString()}`;
-			history.pushState(filters, '', newURL);
-
 		}
-
-		this.filters = filters;
-		this.updateContentFor(filters.content);
-
-		this.updateFilterControls();
-
-		this.loading.setContent(contents);
-		this.fetchFeed(true);
-	}
-
-	updateFilterControls() {
-		this.filterControls.hidden = this.selectedTerms.children.length < 2;
+		return true;
 	}
 
 	/**
-	 * Toggles taxonomy selectors and certain order/orderby options
-	 *  depending on current content
-	 * @param content
+	 * Update URL with current filters (for sharing/bookmarking)
 	 */
-	updateContentFor(content) {
-		this.filterForm.querySelectorAll('.jvb-selector').forEach(tax => {
-			let hasContent = tax.dataset.for.includes(content);
-			tax.hidden = !hasContent;
-			if (!hasContent) {
-				let t = tax.dataset.taxonomy;
-				this.clearSelectedTerms(t);
+	updateURL() {
+		const params = new URLSearchParams();
+
+		// Add simple filters
+		['content', 'order', 'orderby', 'match'].forEach(key => {
+			if (this.filters[key]) {
+				params.set(`f_${key}`, this.filters[key]);
 			}
 		});
-		this.filterForm.querySelectorAll('input[data-for]').forEach(toggle => {
-			toggle.hidden = !toggle.dataset.for.includes(content);
+
+		// Add taxonomy filters
+		Object.entries(this.taxonomyFilters).forEach(([taxonomy, terms]) => {
+			if (terms.length > 0) {
+				params.set(`f_tax_${taxonomy}`, terms.join(','));
+			}
 		});
-		this.filterForm.querySelectorAll('input[name="order"]').forEach(order => {
-			order.hidden = this.filters.order === 'random';
-		});
+
+		// Update URL without reload
+		const newURL = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
+		window.history.pushState({ filters: this.filters }, '', newURL);
 	}
 
-	clearSelectedTerms(taxonomy) {
-		this.filterForm.querySelector(`input[name="${taxonomy}"]`).value = '';
-		if (Object.hasOwn(this.taxonomies, taxonomy)) {
-			this.taxonomies[taxonomy].selectedItems = {};
-		}
-	}
-
-	setSelectedTerms(taxonomy) {
-		let input = this.filterForm.querySelector(`input[name="${taxonomy}"]`);
-		input.value = '';
-		let selected = this.taxonomies[taxonomy].selectedTerms;
-		if (!window.isEmptyObject(selected)) {
-			let ids = Object.keys(selected);
-			input.value = ids.join(',');
-		}
-		this.updateFilters();
-	}
-
-
-	nextPage() {
-		if (this.hasMore) {
-			this.page++;
-		}
-	}
-	resetPage() {
-		this.page = 1;
-		this.hasMore = true;
-	}
-	resetState() {
-		this.resetPage(true);
-		this.isLoading = false;
-		this.retries = {
-			count: 0,
-			max: 3,
-			delay: 1000
-		};
-	}
-
-	resetFilters() {
-		this.filterForm.reset();
-		//check the first content
-		this.filterForm.querySelector('input[name="content"]').checked = true;
-		this.filterForm.querySelector('input[name="orderby"][value="date"]').checked = true;
-		this.page = 1;
-		this.updateFilters();
-	}
-
-
-	buildFilterRequest() {
-
-		let filters = {};
-
-		for (let [filter, value] of Object.entries(this.filters)) {
-			if (value !== false && value !== '') {
-				filters[filter] = value;
-			}
-		}
-		filters.page = parseInt(this.page);
-		if (this.container.dataset.context) {
-			filters.context = this.container.dataset.context;
-		}
-		if (this.container.dataset.source) {
-			filters.source = this.container.dataset.source;
-		}
-		return new URLSearchParams(filters).toString();
-	}
-
-	async fetchFeed(reset = false, force = false) {
-		if (this.isLoading) {
-			return false;
-		}
-		this.loading.showLoading(this.filters);
-		try {
-			if (this.page === 1) {
-				window.removeChildren(this.grid);
-				this.addPlaceholders();
-			}
-
-			const data = await this.cache.fetchWithCache(
-				`${this.config.api}feed?${this.buildFilterRequest()}`,
-				{
-					method: 'GET',
-				},
-				{
-					context: 'feed',
-					forceRefresh: true
-					// forceRefresh: force
-				}
-			);
-
-			//Handle empty results
-			if (!data || !data.items || data.items.length === 0) {
-				if (this.page === 1) {
-					this.showEmptyState();
-				}
-				this.hasMore = false;
-				return false;
-			} else {
-				this.hasMore = data['has_more'];
-
-				this.renderItems(data.items, this.page > 1);
-
-				if (this.hasMore) {
-					this.nextPage();
-				}
-				return true;
-			}
-		} catch (error) {
-			this.handleError(error);
-		} finally {
-			this.loading.hideLoading();
-			if (this.openGallery !== false) {
-				this.gallery.openWhenReady = this.openGallery;
-				this.openGallery = false;
-			}
-			this.loadMore.disabled = false;
-			this.loadMore.hidden = !this.hasMore;
-		}
-	}
-
-	removePlaceholders() {
-		if (this.grid.querySelector('.placeholder')) {
-			window.removeChildren(this.grid);
-		}
-	}
-	showEmptyState() {
-		window.removeChildren(this.grid);
-		let template = window.getTemplate('emptyState');
-		let isFavourite = Object.hasOwn(this.filters, 'favourites') && this.filters.favourites === true;
-		if (isFavourite) {
-			[
-				template.querySelector('h3').textContent,
-				template.querySelector('p:first-of-type').textContent,
-				template.querySelector('p:last-of-type').textContent,
-			] = [
-				'♡ BLANK CANVAS ♡',
-				'You haven\'t fallen in love with any pieces... yet!',
-				'Hit that heart icon when something stops your scroll — your dream collection is waiting to start.'
-			];
-		}
-		this.grid.append(template);
-		this.a11y.announceEmpty(isFavourite);
-
-	}
-	handleError(error){
-		return this.error.handleApiError(
-			error,
-			{
-				component: 'Feed Block',
-				action: 'loaditems'
-			},
-			() => this.fetchFeed()
-		);
-	}
-
-	addPlaceholders() {
-		let total = this.contentTypes.length - 1;
-		for (let i = 0; i < 9; i++) {
-			let template = window.getTemplate('placeholderTemplate');
-			let rand =  Math.floor(Math.random()*total+1);
-			let icon = window.getIcon(this.contentTypes[rand]).cloneNode(true);
-
-			template.append(icon);
-			this.grid.append(template);
-		}
-	}
-	renderItems(items, append = false) {
-		//Clear the grid if we aren't appending
-		if (!append) {
-			window.removeChildren(this.grid);
-			this.addPlaceholders();
+	renderItems() {
+		let items = this.store.getFiltered();
+		if (this.store.filters['page'] === 1) {
+			window.removeChildren(this.ui.grid);
 		}
 
-
-		//Bail early if no items
 		if (items.length === 0) {
-			this.a11y.announceUpdate(0, append);
+			this.a11y.announceItems(0, this.store.filters['page'] > 0);
 			return;
 		}
 
-		//Use DocumentFragment for better performance
 		const fragment = document.createDocumentFragment();
-
 		const batchSize = 10;
+
 		const processBatch = (startIndex) => {
 			const endIndex = Math.min(startIndex + batchSize, items.length);
 
 			for (let i = startIndex; i < endIndex; i++) {
 				const item = items[i];
 				const element = this.createItemElement(item);
-				fragment.appendChild(element);
 
-				this.imageObserver.observe(element);
+				fragment.appendChild(element);
 			}
 
 			if (endIndex < items.length) {
-				requestAnimationFrame(() => {
-					processBatch(endIndex);
-				});
+				requestAnimationFrame(() => processBatch(endIndex));
 			} else {
 				this.removePlaceholders();
-				//all batches are processed, append fragment
-				this.grid.appendChild(fragment);
+				this.ui.grid.append(fragment);
+
 				if (this.config.gallery) {
 					this.gallery.updateGalleryItems(this.gallery.getGalleryItems());
 				}
-				this.a11y.makeNavigable(this.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
-				this.a11y.announceItems(items.length, append, this.hasMore);
+
+				this.a11y.makeNavigable(this.ui.grid.querySelectorAll('.item:not([data-keyboard-nav])'));
+				this.a11y.announceItems(items.length, this.store.filters['page'] > 1, this.store.hasMore);
 			}
 		};
 
 		if (items.length > 0) {
 			processBatch(0);
 		} else {
-			this.a11y.announceUpdate(0, append);
+			this.a11y.announceItems(0, this.store.filters['page'] >1, false);
+		}
+
+		if (this.ui.filters.match) {
+			this.ui.filters.match.hidden = Object.keys(this.taxonomyFilters).length === 0;
+		}
+		if (this.ui.clearFilter) {
+			this.ui.clearFilter.hidden = Object.keys(this.taxonomyFilters).length === 0;
 		}
 	}
 
 	/**
-	 * Creates a feed-item. Used by RenderItems
+	 *
+	 * @param {object} 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);
-		}
+		let template = window.getTemplate(`feedItem${window.uppercaseFirst(item.content)}`);
 
-		const favourited = window.isFavourited(item.icon, item.id)??false;
-		const template = window.getTemplate('feed-item');
+		const isTimeline = Object.hasOwn(template.dataset, 'timeline');
 
-		template.id = `${item.icon}-${item.id}`;
-		template.dataset.id = item.id;
-		template.classList.add(item.icon);
+		// Format fields using helpers
+		for (let [fieldName, value] of Object.entries(item.fields)) {
+			if (isTimeline && ['timeline', 'number'].includes(fieldName)) continue;
+			let el = template.querySelector(`[data-field="${fieldName}"]`);
+			if (!el) continue;
 
-		if (item['umami_view']) {
-			this.buildUmamiData(template, item['umami_view']);
-		}
+			if (value === '') {
+				el.remove();
+				continue;
+			}
 
-		let favouriteButton = template.querySelector('button.favourite');
-		[
-			favouriteButton.dataset.id,
-			favouriteButton.dataset.type,
-			favouriteButton.dataset.artist,
-			favouriteButton.title
-		] = [
-			item.id,
-			item.icon,
-			item['user_id'],
-			(favourited) ? 'Remove from Favourites' : 'Add to Favourites'
-		];
-
-		let order = item.order;
-		let single = template.querySelector('.item');
-		let list = template.querySelector('.item-list');
-		let img = template.querySelector('.feed-images');
-		let summary = template.querySelector('summary');
-		let info = template.querySelector('.item-info');
-
-		for (let [index, id] of Object.entries(order)) {
-			let target;
-			let config = item[id];
-			if (id === 'title') {
-				target = template.querySelector('h3 a');
-				if (item.title !== '') {
-					[
-						target.textContent,
-						target.href,
-						target.url
-					] = [
-						item.title,
-						item.url,
-						`Learn more about this ${item.icon}`
-					];
-					if (item.icon !== '') {
-						target.closest('h3').prepend(window.getIcon(item.icon));
-					}
-					if (item.umami_click) {
-						this.buildUmamiData(target, item.umami_click);
-					}
-				} else {
-					target.remove();
-				}
-			}  else if (Object.hasOwn(config, 'terms')) {
-				//Taxonomy list
-				if (config.terms.length === 0) {
-					continue;
-				}
-				let taxonomy = list.cloneNode(true);
-				let label = taxonomy.querySelector('.label');
-				let termList = taxonomy.querySelector('ul');
-				let listItem = taxonomy.querySelector('li');
-
-				if (config.label) {
-					label.textContent = config.label;
-				}
-				if (config.icon) {
-					label.prepend(window.getIcon(config.icon));
-				}
-				if (!config.label && !config.icon){
-					label.remove();
-				}
-
-				config.terms.forEach(term => {
-					let termItem = listItem.cloneNode(true);
-					let link = termItem.querySelector('a');
-					[
-						link.href,
-						link.title,
-						link.textContent
-					] = [
-						term.url,
-						`Learn more about ${term.title}`,
-						term.title
-					];
-					if (term.umami_click.length > 0) {
-						this.buildUmamiData(link, term.umami_click);
-					}
-					termList.append(termItem);
-				});
-
-				listItem.remove();
-				info.appendChild(taxonomy);
-			} else if (Object.hasOwn(config, 'value') && config.value !== '') {
-				let itemInfo = single.cloneNode(true);
-				let label = itemInfo.querySelector('.label');
-				let link = itemInfo.querySelector('a');
-				let p = itemInfo.querySelector('p');
-				if (Object.hasOwn(config, 'label')) {
-					label.textContent = config.label;
-				}
-				if (Object.hasOwn(config, 'icon')) {
-					label.prepend(window.getIcon(config.icon));
-				}
-				if (!Object.hasOwn(config, 'icon') && !Object.hasOwn(config, 'label')) {
-					label.remove();
-				}
-				if (Object.hasOwn(config, 'url')) {
-					p.remove();
-					[
-						link.textContent,
-						link.href,
-						link.title
-					] = [
-						config.value,
-						config.url,
-						`Learn more about ${config.value}`
-					];
-				} else {
-					link.remove();
-					p.textContent = config.value;
-				}
-				info.appendChild(itemInfo);
-			} else if (id === 'image') {
-				let images = summary.querySelector('.feed-images');
-				let img = images.querySelector('a');
-
-				let main = img.cloneNode(true);
-				if (!this.config.gallery) {
-					main.href = item.url;
-				}
-
-				main.classList.add('feed-image');
-				this.buildImageData(main.querySelector('img'), item.image);
-				images.append(main);
-
-				if (item.content?.length > 0) {
-					images.classList.add('multi');
-					item.content.forEach(c => {
-						let image = img.cloneNode(true);
-						if (!this.config.gallery) {
-							image.href = c.url;
-						}
-						let itemImg = image.querySelector('img');
-						itemImg.src = c.image.small;
-						itemImg.alt = c.image.alt;
-						images.append(image);
-					});
-				}
-				img.remove();
+			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);
 			}
 		}
-		single.remove();
-		list.remove();
 
-		this.rendered[item.icon].set(item.id, template);
+		// Handle link
+		let link = template.querySelector('a');
+		if (link && item.url !== '') {
+			[
+				link.href,
+				link.title
+			] = [
+				item.url,
+				`View ${item.fields['post_title']??'Item'}`
+			];
+		}
+
+		if (isTimeline) {
+			this.addTimelineElements(item, template);
+		}
 
 		return template;
 	}
-
-	buildImageData(img, data){
-		if (typeof data.tiny !== 'string') {
-			return;
+	splitIDs(value) {
+		return String(value).split(',').map((value) => parseInt(value.trim())).filter(value=>value);
+	}
+	isImageField(item, value) {
+		if (!Object.hasOwn(item, 'images') || Object.keys(item.images).length === 0) {
+			return false;
 		}
+		let values = this.splitIDs(value);
+
+		return values.some(v =>
+			Object.keys(item.images).map(k => parseInt(k)).includes(parseInt(v))
+		);
+	}
+	formatImageFields(element, value, item) {
+		let values = this.splitIDs(value); // Convert string to array first
+		if (values.length === 0) return;
+
+		if (values.length > 1) {
+			let image = element.querySelector('img');
+			if (!image) return;
+			values.forEach(imgID => {
+				let img = image.cloneNode(true);
+				this.formatImageField(img, imgID, item);
+				element.append(img);
+			});
+			image.remove();
+		} else {
+			if (element.tagName !== 'IMG') {
+				element = element.querySelector('img');
+				if (!element) return;
+			}
+			this.formatImageField(element, values[0], item);
+		}
+	}
+	formatImageField(element, value, item) {
+		let imgData = item.images[value]??false;
+		if (!imgData) return;
 		[
-			img.src,
-			img.dataset.small,
-			img.dataset.medium,
-			img.dataset.large,
-			img.alt
-		] =
+			element.src,
+			element.srcset,
+			element.alt
+		] = [
+			imgData.tiny,
+			`${imgData.tiny} 50w, ${imgData.small} 300w, ${imgData.medium} 1024w`,
+			imgData['image-alt-text']
+		]
+	}
+	isTaxonomyField(item, field) {
+		if (!Object.hasOwn(item, 'taxonomies') || Object.keys(item.taxonomies).length === 0) {
+			return false;
+		}
+
+		return Object.keys(item.taxonomies).includes(field);
+	}
+	formatTaxonomyField(element, item, field, value) {
+		if (element.tagName !== 'UL' || !element.querySelector('li')) return;
+		let values = this.splitIDs(value);
+		if (values.length === 0) {
+			element.remove();
+		}
+		let listItem = element.querySelector('li');
+		for (let termID of values) {
+			let term = item.taxonomies[field][termID]??false;
+			if (!term) continue;
+			let termItem = listItem.cloneNode(true);
+			let link = termItem.querySelector('a');
+			if (!link) continue;
+
 			[
-				data.tiny,
-				data.small,
-				data.medium,
-				data.large,
-				data.alt
+				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;
 	}
 
-	buildUmamiData(item, data){
-		for(let [key, value] of Object.entries(data)){
-			item.dataset[key] = value;
+	addTimelineElements(item, template) {
+		let [
+			afterEl,
+			number,
+			started,
+			last
+		] = [
+			template.querySelector('span.after-text'),
+			template.querySelector('[data-field="number"] b'),
+			template.querySelector('[data-field="started"] time'),
+			template.querySelector('[data-field="updated"] time')
+		];
+
+		if (afterEl) {
+			afterEl.textContent = `After ${item.fields.number} Tx`;
+		}
+		if (number) {
+			number.textContent = item.fields.number;
+		}
+		if (started) {
+			this.formatTimeField(started, item.fields.timeline[0]['post_date']);
+		}
+		if (last) {
+			this.formatTimeField(last, item.fields.timeline[item.fields.timeline.length - 1]['post_date']);
 		}
 	}
 
-	/**
-	 * Load Image, used by renderItems
-	 * @param element
-	 */
-	loadImage(element) {
-		const img = element.querySelector('img');
-		if (!img) return;
-		const size = this.getImageSize();
-
-		img.src = img.dataset[size] || img.dataset.src;
-		element.setAttribute('data-loaded', 'true');
+	removePlaceholders() {
+		const placeholders = this.ui.grid.querySelectorAll('.placeholder');
+		if (placeholders.length > 0) {
+			placeholders.forEach(p => p.remove());
+		}
 	}
 
+
+	addPlaceholders() {
+		let total = this.contentTypes.length;
+		const fragment = document.createDocumentFragment();
+		for (let i = 0; i < 12; i++) {
+			let template = window.getTemplate('placeholderTemplate');
+
+			let rand = Math.floor(Math.random() * total);
+			let icon;
+			if (this.ui.content && this.ui.content.length > 0) {
+				icon = this.ui.content.filter((content) => { return content.value === this.contentTypes[rand]}).querySelector('.icon').cloneNode(true);
+			} else {
+				icon = window.getIcon(this.container.dataset.icon);
+			}
+			template.append(icon);
+			fragment.append(template);
+		}
+		this.ui.grid.append(fragment);
+	}
+
+
+
 	/**
-	 * Updates the image size according to screen size
+	 *
+	 * @param {object} filters {name: value}
 	 */
-	updateImageSizes() {
-		const size = this.getImageSize();
-		const items = this.grid.querySelectorAll('.item');
-		items.forEach(item => {
-			const img = item.querySelector('img');
-			if (img && img.dataset[size] && img.src !== img.dataset[size]) {
-				img.src = img.dataset[size];
+	updateFilter(filters) {
+		//double check filters are what we're expecting
+		let allowed = ['taxonomy','favourites','match', ... Object.keys(this.filters)];
+
+		filters = Object.keys(filters)
+			.filter(key => allowed.includes(key))
+			.reduce((obj, key) => {
+				obj[key] = filters[key];
+				return obj;
+			}, {});
+
+		if (window.getDifferences.map(this.filters, filters)) {
+			this.filters = { ...this.filters, ...filters };  // Merge instead of replace
+			this.updateURL();
+			this.store.setFilters(filters);
+		}
+	}
+	/**
+	 * Update visible filters based on selected content type
+	 */
+	updateContentFor(contentType) {
+		// Update taxonomy filter visibility
+		const taxonomyButtons = this.ui.filterContainer.querySelectorAll('[data-filter="taxonomy"]');
+		taxonomyButtons.forEach(button => {
+			const forTypes = button.dataset.for?.split(',') || [];
+			button.hidden = forTypes.length > 0 && !forTypes.includes(contentType);
+		});
+
+		// Update ordering options
+		const orderButtons = this.ui.filterContainer.querySelectorAll('[data-for]');
+		orderButtons.forEach(button => {
+			const forTypes = button.dataset.for?.split(',') || [];
+			if (forTypes.length > 0) {
+				button.hidden = !forTypes.includes(contentType);
+				// Uncheck if hiding
+				if (button.hidden && button.checked) {
+					button.checked = false;
+				}
 			}
 		});
-	}
-	/**
-	 * Get image size based on screen width
-	 */
-	getImageSize() {
-		const width = window.innerWidth;
-		if (width > 1024) return 'medium';
-		if (width > 500) return 'medium';
-		return 'small';
+
+		// Update order direction visibility based on selected orderby
+		const orderBy = this.ui.filterContainer.querySelector('[name="orderby"]:checked');
+		this.updateOrderDirectionVisibility(orderBy?.value);
 	}
 
+	/**
+	 * Show/hide order direction based on orderby selection
+	 */
+	updateOrderDirectionVisibility(orderBy) {
+		const orderDirection = this.ui.filterContainer.querySelector('.order-direction');
+		if (orderDirection) {
+			const forOrders = orderDirection.dataset.forOrder?.split(',') || [];
+			orderDirection.hidden = forOrders.length > 0 && !forOrders.includes(orderBy);
+		}
+	}
+	/*********************************************************************
+	LISTENERS
+	 *********************************************************************/
+	initListeners() {
+		this.popStateHandler 	= this.handlePopState.bind(this);
+		this.clickHandler		= this.handleClick.bind(this);
+		this.changeHandler		= this.handleChange.bind(this);
+		this.imageObserver = null;
+		this.resizeObserver = null;
+		if ('IntersectionObserver' in window) {
+			this.imageObserver = new IntersectionObserver(entries => {
+				entries.forEach(entry => {
+					this.loadImage(entry.target);
+					this.imageObserver.unobserve(entry.target);
+				});
+			}, {
+				rootMargin: '100px',
+				threshold: .1
+			});
+		}
+
+		if ('ResizeObserver' in window) {
+			this.resizeObserver = new ResizeObserver(() => {
+				window.debouncer.schedule(
+					'feed-update-images',
+					() => this.updateImageSizes(),
+					250
+				);
+			});
+		} else {
+			window.addEventListener('resize', () => {
+				window.debouncer.schedule(
+					'feed-update-images',
+					() => this.updateImageSizes(),
+					250
+				);
+			});
+		}
+
+		window.addEventListener('popstate', this.popStateHandler);
+		document.addEventListener('click', this.clickHandler);
+		document.addEventListener('change', this.changeHandler);
+	}
+
+	handlePopState(e) {
+		if (e.state?.filters) {
+			if (this.processURLFilters()) {
+				this.store.setFilters(this.filters);
+				this.a11y.announce('Feed filters updated from browser history');
+			}
+		}
+	}
+
+	handleClick(e) {
+		if (window.targetCheck(e, this.elements.loadMore)) {
+			this.nextPage();
+		} else if (window.targetCheck(e, this.elements.clearFilter)) {
+			this.clearAllTaxonomies();
+		} else if (window.targetCheck(e, '.remove-item')) {
+			this.handleRemoveSelectedTerm(e);
+		}
+	}
+
+	handleRemoveSelectedTerm(e) {
+		const selectedItem = e.target.closest('.selected-item');
+		if (!selectedItem) return;
+
+		const termId = parseInt(selectedItem.dataset.id);
+		const taxonomy = selectedItem.dataset.taxonomy;
+
+		// Remove from filters
+		if (this.taxonomyFilters[taxonomy]) {
+			this.taxonomyFilters[taxonomy] = this.taxonomyFilters[taxonomy]
+				.filter(id => id !== termId);
+
+			if (this.taxonomyFilters[taxonomy].length === 0) {
+				delete this.taxonomyFilters[taxonomy];
+			}
+		}
+
+		// Remove from UI
+		selectedItem.remove();
+
+		// Update filters
+		this.updateFilter({
+			taxonomy: Object.keys(this.taxonomyFilters).length > 0
+				? this.taxonomyFilters
+				: null,
+			page: 1
+		});
+	}
+
+	handleChange(e) {
+		let target = e.target;
+		if (Object.hasOwn(target.dataset, 'filter')) {
+			if (target.dataset.filter === 'content') {
+				this.updateContentFor(target.value);
+				this.updateFilter({ content: target.value, page: 1 });
+			} else if (target.dataset.filter === 'orderby') {
+				this.updateOrderDirectionVisibility(target.value);
+				this.updateFilter({ orderby: target.value, page: 1 });
+			} else if (target.dataset.filter === 'order') {
+				this.updateFilter({ order: target.value, page: 1 });
+			} else if (target.dataset.filter === 'match') {
+				this.updateFilter({ match: target.checked ? 'all' : 'any', page: 1 });
+			} else if (target.dataset.filter === 'favourites') {
+				this.updateFilter({ favourites: target.checked, page: 1 });
+			}
+		}
+	}
 }
-document.addEventListener('DOMContentLoaded', () => {
-	window.feedBlock = new FeedBlock();
+
+document.addEventListener('DOMContentLoaded', async function() {
+	window.auth.subscribe(event => {
+		if (event === 'auth-loaded') {
+			window.feedBlock = new FeedBlock();
+		}
+	});
 });

--
Gitblit v1.10.0