From b38f03c0e7218762d90fa5092696b127f24f36db Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 25 Jan 2026 07:07:26 +0000
Subject: [PATCH] =Some logical flaws in Queue.php, Queue.js, ContentExecutor.php, UploadExecutor.php - particularly with timeline ordering, frontend queue updates, etc

---
 inc/managers/SchemaManager.php                   |    6 
 src/summary/render.php                           |   22 
 inc/blocks/SummaryBlock.php                      |    8 
 inc/managers/Cache.php                           |  652 ++++++
 jvb.php                                          |    6 
 inc/helpers/formatting.php                       |   33 
 inc/rest/routes/TermRoutes.php                   |   10 
 inc/managers/OperationQueue.php                  |   16 
 assets/js/min/form.min.js                        |    2 
 inc/managers/DashboardManager.php                |   49 
 inc/rest/routes/ContentRoutes.php                |  175 +
 inc/rest/routes/FeedRoutes.php                   |  200 
 inc/meta/MetaFormOld.php                         |    4 
 inc/rest/routes/ApprovalRoutes.php               |    4 
 inc/managers/_setup.php                          |   16 
 assets/js/concise/CRUD.js                        |   66 
 inc/helpers/crud.php                             |    4 
 inc/rest/routes/ResponseRoutes.php               |   26 
 JVBase.php                                       |    7 
 build/summary/render.php                         |   22 
 inc/managers/LoginManager.php                    |    4 
 inc/integrations/Square.php                      |    2 
 inc/meta/MetaManager.php                         |    3 
 assets/js/concise/Queue.js                       |  129 +
 inc/integrations/Integrations.php                |    8 
 inc/rest/routes/SEORoutes.php                    |    8 
 src/feed/style.scss                              |    8 
 inc/rest/routes/NewsRoutes.php                   |    4 
 build/drawer-menu/render.php                     |    5 
 inc/helpers/breadcrumbs.php                      |    2 
 build/feed/style-index.css                       |    2 
 assets/js/concise/UploadManager.js               |   46 
 src/feed/view.js                                 |   42 
 activate.php                                     |    2 
 inc/rest/routes/ReferralRoutes.php               |   12 
 inc/blocks/FAQBlock.php                          |   10 
 inc/managers/SEO/_edmonotonink.php               |    4 
 inc/managers/MagicLinkManager.php                |   12 
 inc/managers/queue/Storage.php                   |   13 
 inc/rest/routes/UploadRoutes.php                 |    3 
 assets/js/min/populate.min.js                    |    2 
 inc/managers/SEO/SEOAdminPage.php                |    1 
 inc/managers/SEO/SchemaOutputManager.php         |   16 
 inc/integrations/GoogleMyBusiness.php            |    5 
 inc/blocks/GlossaryBlock.php                     |    9 
 checks.php                                       |   47 
 inc/managers/CacheManagerOld.php                 |  930 ++++++--
 inc/managers/queue/executors/UploadExecutor.php  |   28 
 assets/js/concise/TaxonomySelector.js            |   59 
 inc/managers/ScriptLoader.php                    |    2 
 inc/utility/Image.php                            |   33 
 inc/rest/routes/Invitations.php                  |    2 
 inc/rest/routes/SettingsRoutes.php               |   12 
 assets/js/concise/DataStore.js                   |  133 
 src/drawer-menu/render.php                       |    5 
 assets/js/min/dataStore.min.js                   |    2 
 build/feed/view.js                               |    2 
 inc/rest/routes/LoginRoutes.php                  |    4 
 inc/integrations/Helcim.php                      |    2 
 inc/users/UserSettings.php                       |    2 
 build/list/render.php                            |    4 
 inc/rest/routes/NotificationsRoutes.php          |   12 
 inc/helpers/ui.php                               |    2 
 inc/forms/TaxonomySelector.php                   |    6 
 inc/registry/TaxonomyRegistrar.php               |   33 
 inc/managers/CRUDManager.php                     |    6 
 assets/js/concise/UtilityFunctions.js            |   41 
 assets/js/min/queue.min.js                       |    2 
 inc/blocks/CustomBlocks.php                      |   15 
 inc/rest/routes/OptionsRoutes.php                |    6 
 assets/js/min/utility.min.js                     |    2 
 inc/managers/SEO/TemplateResolver.php            |    4 
 inc/blocks/TimelineBlock.php                     |   16 
 inc/managers/queue/executors/ContentExecutor.php |   66 
 inc/managers/NewsRelationships.php               |    8 
 inc/managers/TaxonomyRelationships.php           |   11 
 inc/managers/NotificationManager.php             |  248 +-
 inc/managers/IconsManager.php                    |   48 
 build/feed/style-index-rtl.css                   |    2 
 inc/helpers/time.php                             |   14 
 assets/js/min/selector.min.js                    |    2 
 assets/js/concise/FormController.js              |   43 
 inc/managers/DirectoryManager.php                |   26 
 inc/integrations/Umami.php                       |    4 
 inc/rest/routes/FormRoutes.php                   |    6 
 inc/blocks/FeedBlock.php                         |   63 
 inc/managers/SEO/BreadcrumbManager.php           |   33 
 inc/registry/PostTypeRegistrar.php               |    1 
 inc/managers/UserTermsManager.php                |   70 
 assets/js/min/crud.min.js                        |    2 
 inc/EmbedGenerator.php                           |    2 
 assets/js/concise/PopulateForm.js                |    7 
 inc/forms/PostSelector.php                       |    9 
 inc/helpers/renderFields.php                     |   16 
 inc/rest/routes/FavouritesRoutes.php             |  212 +
 assets/js/concise/TaxonomyCreator.js             |    7 
 inc/managers/ReferralManager.php                 |  154 
 inc/managers/queue/Queue.php                     |   30 
 inc/helpers/members.php                          |   87 
 inc/blocks/MenuBlock.php                         |   24 
 inc/rest/routes/QueueRoutes.php                  |    9 
 inc/managers/AdminPages.php                      |  147 
 inc/managers/SEOMetaManager.php                  |   34 
 assets/js/min/uploader.min.js                    |    2 
 assets/js/min/creator.min.js                     |    2 
 build/feed/view.asset.php                        |    2 
 templates/dashboard/sections/news.php            |    6 
 /dev/null                                        | 1206 -----------
 src/list/render.php                              |    4 
 inc/rest/RestRouteManager.php                    |  472 ++--
 inc/rest/routes/AdminRoutes.php                  |    2 
 inc/blocks/FormBlock.php                         |   24 
 112 files changed, 3,190 insertions(+), 3,007 deletions(-)

diff --git a/JVBase.php b/JVBase.php
index d2b0ad9..b6b64a5 100644
--- a/JVBase.php
+++ b/JVBase.php
@@ -3,7 +3,7 @@
 
 use JVBase\blocks\CustomBlocks;
 use JVBase\integrations\BlueSky;
-use JVBase\managers\CacheManager;
+use JVBase\managers\cache\Cache;
 use JVBase\managers\EmailManager;
 use JVBase\managers\ErrorHandler;
 use JVBase\managers\LoginManager;
@@ -217,10 +217,7 @@
     {
         return $this->managers['file'];
     }
-    public function cache():CacheManager
-    {
-        return $this->managers['cache'];
-    }
+
     public function queue():Queue
     {
         return $this->managers['queue'];
diff --git a/activate.php b/activate.php
index 0dad565..83ce7d5 100644
--- a/activate.php
+++ b/activate.php
@@ -1,6 +1,7 @@
 <?php
 
 use JVBase\integrations\Umami;
+use JVBase\managers\Cache;
 use JVBase\managers\ReferralManager;
 use JVBase\managers\SEO\SEOAdminPage;
 use JVBase\utility\Features;
@@ -203,6 +204,7 @@
     jvbDeleteOptions();
     jvbDeleteDashboard();
     jvbDeleteDirectories();
+	Cache::flushAll();
     do_action('jvbDeactivate');
 }
 
diff --git a/assets/js/concise/CRUD.js b/assets/js/concise/CRUD.js
index 6eee959..8d92f41 100644
--- a/assets/js/concise/CRUD.js
+++ b/assets/js/concise/CRUD.js
@@ -42,8 +42,8 @@
 
 		const baseSetup = (el, refs, data) => {
 			el.dataset.itemId = data.id;
-
-			window.prefixInput(refs.checkbox, `select-${data.id}`, true);
+			let wrapper = refs.checkbox.closest('.preview');
+			window.prefixInput(refs.checkbox, `select-${data.id}`, wrapper, true);
 			refs.checkbox.value = data.id;
 			refs.checkbox.checked = crud.selected.has(parseInt(data.id));
 			if (refs.selectLabel) refs.selectLabel.htmlFor = `select-${data.id}`;
@@ -131,7 +131,8 @@
 				baseSetup(el, refs, data);
 
 				manyRefs?.inputs?.forEach(el => {
-					window.prefixInput(el, `${data.id}-`);
+					let wrapper = el.closest('[data-field]');
+					window.prefixInput(el, `${data.id}-`, wrapper);
 				});
 
 				manyRefs?.status?.forEach(el => {
@@ -143,7 +144,8 @@
 				if (crud.isTimeline) {
 					if (refs.sharedRow) {
 						refs.sharedRow.querySelectorAll('input,select,textarea').forEach(input => {
-							window.prefixInput(input, `${data.id}-`);
+							let wrapper = input.closest('[data-field]');
+							window.prefixInput(input, `${data.id}-`, wrapper);
 						});
 
 						crud.populate.populate(refs.sharedRow, data);
@@ -164,7 +166,8 @@
 							point.dataset.itemId = timeline.id;
 
 							point.querySelectorAll('input,select,textarea').forEach(input => {
-								window.prefixInput(input, `${timeline.id}-`);
+								let wrapper = input.closest('[data-field]');
+								window.prefixInput(input, `${timeline.id}-`, wrapper);
 							});
 
 							crud.populate.populate(point, {
@@ -185,7 +188,8 @@
 					if (crud.ui.table.form?.dataset.edit !== undefined) {
 						// Non-timeline: prefix all inputs normally
 						manyRefs?.inputs?.forEach(input => {
-							window.prefixInput(input, `${data.id}-`);
+							let wrapper = input.closest('[data-field]');
+							window.prefixInput(input, `${data.id}-`, wrapper);
 						});
 
 						manyRefs?.status?.forEach(el => {
@@ -480,37 +484,35 @@
 			}
 			if (event === 'operation-status'
 				&& data.status === 'completed'
-				&& data.endpoint === 'content'
-				&& Object.keys(data.data?.posts??{}).length > 0) {
+				&& data.endpoint === 'uploads/groups') {
 
+				console.log('Cleared local cache. Refresh to see changes');
 				this.store.clearCache();
-				let ids = Object.keys(data.data.posts);
-				let storedChanges = this.changesStore.getMany(ids);
+			}
+			if (event === 'operation-status'
+				&& data.status === 'completed'
+				&& data.type === 'content_update') {
+				console.log('Cleared local cache. Refresh to see changes');
+				this.store.clearCache();
 
-				this.changesStore.deleteMany(ids);
-
-				for (let id of ids) {
-					let stored = storedChanges.filter(change => change.id === id)[0]??false;
-
-					let sentChanges = data.data.posts[id];
-					let remainingChanges = {};
-
-					for (let [key, value] of Object.entries(sentChanges)) {
-						if (stored && !Object.hasOwn(stored, key)) continue;
-						if (stored[key] === value) {
-							delete stored[key];
-						}
-						remainingChanges[key] = value;
-					}
-					if (Object.keys(remainingChanges).length > 0) {
-						remainingChanges['id'] = id;
-						remainingChanges['content'] = this.content;
-						this.changes.set(id, remainingChanges);
-					}
+				// Check for result data (from ContentExecutor)
+				if (!data.result || !data.result.posts) {
+					console.warn('Content update completed but no result.posts', data);
+					return;
 				}
-				if (Object.values(this.changes).length > 0) {
-					this.scheduleBackup();
+
+				// Get successfully processed post IDs
+				const successfulIds = Object.keys(data.result.posts).filter(id => {
+					return data.result.posts[id]?.success === true;
+				});
+
+				if (successfulIds.length === 0) {
+					return;
 				}
+
+				// Clear from both persistent and in-memory storage
+				this.changesStore.deleteMany(successfulIds);
+				successfulIds.forEach(id => this.changes.delete(id));
 			}
 
 		});
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 58ec874..29dc9c7 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -45,7 +45,7 @@
 	 * @param {object|array} configs An object defining the store, or an array of objects defining the stores
 	 * @param {number} version the database version
 	 */
-	register(name, configs = [], version = 1.2) {
+	register(name, configs = [], version = 1.25) {
 		if (!Array.isArray(configs)) configs = [configs];
 		if (configs.length === 0) return;
 
@@ -108,6 +108,7 @@
 
 			store.ignoreFilters = new Set([
 				... ['search', 'page', 'per_page', 'orderby', 'order'],
+				... ['context', 'source'],
 				... store.config.ignore
 			]);
 
@@ -517,9 +518,10 @@
 			const cached = store.cache.get(cacheKey);
 
 			if (cached && this.isCacheValid(cached, store.config.TTL)) {
+				let items = cached.items.map(itemId => this.get(name, itemId));
 				this.notify(name, 'data-loaded', {
 					cached: true,
-					items: cached.items || []
+					items: items??[]
 				});
 				return cached;
 			}
@@ -1074,42 +1076,57 @@
 
 		// First check if we have cached results for exact filters
 		if (cacheEntry?.items) {
-			return this.applyOrdering(
-				cacheEntry.items.reduce((acc, id) => {
-					const item = store.data.get(id);
-					if (item) acc.push(item);
-					return acc;
-				}, []),
-				store
-			);
+			const items = cacheEntry.items.reduce((acc, id) => {
+				const item = store.data.get(id);
+				if (item) acc.push(item);
+				return acc;
+			}, []);
+			return this.applyOrdering(items, store);
 		}
 
 		const allItems = Array.from(store.data.values());
+
 		const searchQuery = store.filters.search?.toLowerCase().trim() || '';
 
 		const filterPredicates = [];
+
+		// Handle taxonomy filters separately
+		if (store.filters.taxonomy && typeof store.filters.taxonomy === 'object') {
+			Object.entries(store.filters.taxonomy).forEach(([taxonomy, termIds]) => {
+				const acceptedTermIds = Array.isArray(termIds) ? termIds : [termIds];
+
+				filterPredicates.push(item => {
+					if (!item.taxonomies || !item.taxonomies[taxonomy]) {
+						return false;
+					}
+					const itemTermIds = Object.keys(item.taxonomies[taxonomy]).map(id => parseInt(id));
+					const matches = acceptedTermIds.some(termId => itemTermIds.includes(parseInt(termId)));
+					return matches;
+				});
+			});
+		}
+
+		// Handle other filters
 		for (const [key, value] of Object.entries(store.filters)) {
-			if (store.ignoreFilters.has(key)) continue;
+			if (key === 'taxonomy') continue;
+			if (store.ignoreFilters.has(key)) {
+				continue;
+			}
 			if (value === null || value === undefined || value === '') continue;
 			if (value === 'all') continue;
 
-			// Comma-separated values
 			if (typeof value === 'string' && value.includes(',')) {
 				const accepted = value.split(',').map(v => v.trim());
 				filterPredicates.push(item => accepted.includes(String(item[key])));
-				continue;
+			} else {
+				filterPredicates.push(item => String(item[key]) === String(value));
 			}
-
-			filterPredicates.push(item => String(item[key]) === String(value));
 		}
 
 		const filtered = allItems.filter(item => {
-			// Apply all non-search filters
 			for (const predicate of filterPredicates) {
 				if (!predicate(item)) return false;
 			}
-
-			// Apply search if present
 			return !(searchQuery && !this.searchObject(item, searchQuery));
 		});
 
@@ -1120,37 +1137,50 @@
 		if (!Array.isArray(items)) items = Array.from(items);
 		if (items.length === 0) return items;
 
-		if (store.filters.orderby || store.filters.order) {
-			const orderby = store.filters.orderby || 'date';
-			const order = (store.filters.order || 'desc').toLowerCase();
+		const orderby = store.filters.orderby || 'date';
+		const order = (store.filters.order || 'desc').toLowerCase();
 
-			items.sort((a, b) => {
-				let aVal, bVal;
-
-				switch (orderby) {
-					case 'alphabetical':
-					case 'title':
-						aVal = (a.fields?.post_title || a.title || a.name || '').toLowerCase();
-						bVal = (b.fields?.post_title || b.title || b.name || '').toLowerCase();
-						break;
-					case 'modified':
-						aVal = new Date(a.modified || 0);
-						bVal = new Date(b.modified || 0);
-						break;
-					case 'date':
-					default:
-						aVal = new Date(a.date || 0);
-						bVal = new Date(b.date || 0);
-				}
-
-				if (aVal < bVal) return order === 'asc' ? -1 : 1;
-				if (aVal > bVal) return order === 'asc' ? 1 : -1;
-				return 0;
-			});
+		// Handle random ordering
+		if (['random', 'rand'].includes(orderby) || ['random', 'rand'].includes(order)) {
+			return this.shuffle(items);
 		}
+
+		items.sort((a, b) => {
+			let aVal, bVal;
+
+			switch (orderby) {
+				case 'alphabetical':
+				case 'title':
+					aVal = (a.title || a.name || '').toLowerCase();
+					bVal = (b.title || b.name || '').toLowerCase();
+					break;
+				case 'modified':
+					aVal = new Date(a.modified || a.date || 0);
+					bVal = new Date(b.modified || b.date || 0);
+					break;
+				case 'date':
+				default:
+					aVal = new Date(a.date || a.modified || 0);
+					bVal = new Date(b.date || b.modified || 0);
+			}
+
+			if (aVal < bVal) return order === 'asc' ? -1 : 1;
+			if (aVal > bVal) return order === 'asc' ? 1 : -1;
+			return 0;
+		});
+
 		return items;
 	}
 
+	shuffle(items) {
+		const array = items.slice();
+		for (let i = array.length - 1; i > 0; i--) {
+			const j = Math.floor(Math.random() * (i + 1));
+			[array[i], array[j]] = [array[j], array[i]];
+		}
+		return array;
+	}
+
 	searchObject(obj, search) {
 		if (!obj || typeof obj !== 'object') {
 			return typeof obj === 'string' && obj.toLowerCase().includes(search);
@@ -1201,23 +1231,22 @@
 				store.filters[key] = value;
 			}
 		});
-
 		this.notify(name, 'filters-changed', {
 			oldFilters,
 			filters: store.filters,
 			updates
 		});
 
-		this.notify(name, 'data-loaded', {
-			cached: true,
-			items: this.getFiltered(name)
-		});
-
 		const shouldFetch = await this.shouldFetchWithFilters(name, updates, oldFilters);
+
 		if (store.config.endpoint && shouldFetch) {
 			await this.fetch(name);
-		} else if (store.config.endpoint) {
-			this.notify(name, 'data-loaded');
+		} else {
+			const filtered = this.getFiltered(name);
+			this.notify(name, 'data-loaded', {
+				cached: true,
+				items: filtered
+			});
 		}
 	}
 
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 9a3f632..0d7e7ca 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -378,11 +378,14 @@
 		if (this.subscribers.size > 0) {
 			e.preventDefault();
 			console.log('Cancelling scheduled backup and manually backing up');
-			this.cancelBackup();
-			await this.backup();
-			const storedData = await this.store.get(form.id);
+
+
 
 			if (form.options.cache) {
+				this.cancelBackup();
+				await this.backup();
+				const storedData = await this.store.get(form.id);
+
 				this.notify('form-submit', {
 					config: form,
 					data: storedData.changes
@@ -812,7 +815,8 @@
 								let index = config.ui.items?.children?.length??0;
 								el.dataset.index = index;
 								manyRefs.inputs?.forEach(input => {
-									window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
+									let wrapper = el.closest('[data-field]');
+									window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper);
 								});
 							}
 						},
@@ -883,7 +887,8 @@
 								let index = config.ui.items?.children?.length??0;
 								el.dataset.index = index;
 								manyRefs.inputs?.forEach(input => {
-									window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
+									let wrapper = window.closest('.tag-item');
+									window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper)
 								});
 
 								if (refs.label) {
@@ -1179,19 +1184,29 @@
 	 * @param {HTMLElement} container
 	 */
 	reindexList(container) {
+		const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
+
 		Array.from(container.children).forEach((item, index) => {
 			item.dataset.index = `${index}`;
-			Array.from(item.children).forEach(child => {
-				if (child.type === 'hidden') {
-					window.prefixInput(
-						child,
-						`${container.dataset.field}:${index}:${child.dataset.field}`
-					);
-				}
+
+			// Find ALL inputs within this item, not just direct children
+			const inputs = item.querySelectorAll('input, select, textarea');
+
+			inputs.forEach(input => {
+				// Skip inputs that shouldn't be re-indexed (like file inputs)
+				if (input.type === 'file') return;
+
+				// Get the field name from the input's data-field or name
+				const inputField = input.dataset.field || input.name.split(':').pop();
+
+				// Re-prefix with the new index, passing item as wrapper
+				window.prefixInput(
+					input,
+					`${fieldName}:${index}:`,
+					item  // Pass the item as wrapper for label lookup
+				);
 			});
 		});
-
-		//schedule save
 	}
 	/**********************************************************************
 	 VALIDATION
diff --git a/assets/js/concise/PopulateForm.js b/assets/js/concise/PopulateForm.js
index 16d3460..d1c3f2e 100644
--- a/assets/js/concise/PopulateForm.js
+++ b/assets/js/concise/PopulateForm.js
@@ -345,7 +345,8 @@
 				el.dataset.itemId = data.id;
 
 				if (refs.select) {
-					window.prefixInput(refs.select, `${data.id}-`);
+					let wrapper = refs.select.closest('.preview');
+					window.prefixInput(refs.select, `${data.id}-`, wrapper);
 				}
 				if (refs.video) refs.video.remove();
 				if (refs.file) refs.file.remove();
@@ -385,9 +386,9 @@
 						if (!p.isEmptyValue(value)) {
 							p.populateField(field, name, value);
 						}
-						const input = field.querySelector('input:not([type="file"]), textarea');
+						const input = field.querySelector('input:not([type="file"])');
 						if (!input) continue;
-						window.prefixInput(input, `[${data.id}]`);
+						window.prefixInput(input, `[${data.id}]`, field);
 					}
 
 				}
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index 4c9adbc..6c097c1 100644
--- a/assets/js/concise/Queue.js
+++ b/assets/js/concise/Queue.js
@@ -134,6 +134,7 @@
 				actions: {
 					cancel: 'button.cancel',
 					retry: 'button.retry',
+					refresh: 'button.refresh',
 					dismiss: 'button.dismiss',
 				}
 			},
@@ -198,6 +199,13 @@
 				return;
 			}
 
+
+			const refreshPage = window.targetCheck(e, this.selectors.actions.refresh);
+			if (refreshPage) {
+				this.handleRefresh(opId);
+				return;
+			}
+
 			const clear = window.targetCheck(e, this.selectors.actions.clear);
 			if (clear) {
 				this.opActions('completed', 'dismiss').then(()=>{});
@@ -295,22 +303,90 @@
 		this.store.subscribe((event, data) => {
 			switch (event) {
 				case 'data-loaded':
+					const serverOps = this.store.getAll();
+
+					serverOps.forEach(serverOp => {
+						const localOp = this.queue.get(serverOp.id);
+						const mapped = this.mapServerOperation(serverOp);
+
+						this.queue.set(mapped.id, mapped);
+
+						// Notify if changed
+						if (localOp && localOp.status !== mapped.status) {
+							this.notify('operation-status', mapped);
+						}
+					});
+
+					this.maybeStartPolling();
+					this.updateUI();
+					break;
+
 				case 'items-save':
 					this.maybeStartPolling();
 					this.updateUI();
 					break;
+
 				case 'item-saved':
-					if (data.previousItem && data.previousItem.status !== data.item.status) {
-						this.updateOperationStatus(data.item.id, data.item.status);
+					if (data.item) {
+						this.queue.set(data.item.id, data.item);
+						if (data.previousItem?.status !== data.item.status) {
+							this.notify('operation-status', data.item);
+						}
 					}
 					this.maybeStartPolling();
 					break;
-				default:
-
-					break;
 			}
 		});
 	}
+
+	/**
+	 * Handle refresh button click - clears cache for the relevant store
+	 */
+	handleRefresh(opId) {
+		const op = this.getQueue(opId);
+		if (!op) return;
+
+		// Determine which store to refresh based on operation type
+		let storeName = null;
+
+		// Map operation types to store names
+		const typeToStore = {
+			'content_update': op.data?.posts ? Object.values(op.data.posts)[0]?.content : null,
+			'batch_creation': op.data?.content,
+			'image_upload': 'uploads',
+			'video_upload': 'uploads',
+			'document_upload': 'uploads',
+		};
+
+		storeName = typeToStore[op.type];
+
+		// If we found a store name, clear its cache
+		if (storeName && window.jvbStore) {
+			const store = window.jvbStore.stores.get(storeName);
+			if (store) {
+				window.jvbStore.clearCache(storeName);
+				window.jvbStore.fetch(storeName);
+
+				// Give visual feedback
+				const button = this.items.get(opId)?.ui?.actions?.refresh;
+				if (button) {
+					const originalText = button.querySelector('span').textContent;
+					button.querySelector('span').textContent = 'Refreshed!';
+					button.disabled = true;
+
+					setTimeout(() => {
+						button.querySelector('span').textContent = originalText;
+						button.disabled = false;
+					}, 2000);
+				}
+			}
+		} else {
+			// Fallback: just reload the page if we can't determine the store
+			if (confirm('Refresh the page to see changes?')) {
+				window.location.reload();
+			}
+		}
+	}
 	/****************************************************************************
 	 OPERATIONS
 	****************************************************************************/
@@ -356,14 +432,16 @@
 
 		const existingOps = Array.from(this.getAllQueue()).filter(op=> {
 			return op.status === 'queued' &&
-			op.endpoint === item.endpoint &&
-			op.canMerge
+				op.endpoint === item.endpoint &&
+				op.canMerge
 		});
 		if (existingOps.length > 0) {
 			const existing = existingOps[0];
 			existing.data = window.deepMerge(existing.data, item.data);
 			existing.timestamp = Date.now();
 
+			this.setQueue(existing);
+
 			this.updateOperationStatus(existing.id, existing.status);
 			this.updateUI();
 			this.trackActivity();
@@ -844,6 +922,9 @@
 				item.ui.actions['retry'].hidden = op.status !=='failed';
 			}
 			if (item.ui.actions.dismiss) item.ui.actions.dismiss.hidden = this.pendingStatuses.includes(op.status);
+			if (item.ui.actions.refresh) {
+				item.ui.actions.refresh.hidden = op.status !== 'completed';
+			}
 		}
 		getProgress(op) {
 			if (op.progress) return op.progress;
@@ -901,7 +982,7 @@
 			case 'processing':
 				return item.progress ? `${item.progress}% complete` : 'Processing...';
 			case 'completed':
-				return 'Successfully completed';
+				return 'Successfully completed. Refresh to see changes.';
 			case 'failed':
 				return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`;
 			case 'failed_permanent':
@@ -919,6 +1000,38 @@
 		this.isProcessing = on;
 		this.ui.toggle.button.classList.toggle('saving', on);
 	}
+
+	/**
+	 * Map server operation format to frontend format
+	 * Server uses: type, data (requestData), status (from state/outcome)
+	 * Frontend uses: endpoint, data, status, headers, method, etc.
+	 */
+	mapServerOperation(serverOp) {
+		const localOp = this.queue.get(serverOp.id);
+
+		// If we have local operation data, preserve it
+		if (localOp && localOp.endpoint) {
+			return {
+				...localOp,
+				...serverOp,
+				endpoint: localOp.endpoint,
+				method: localOp.method,
+				headers: localOp.headers,
+			};
+		}
+
+		// Minimal mapping for server-only operations
+		// Extract endpoint from type if possible, otherwise use type
+		const endpoint = serverOp.type ? serverOp.type.replace('_update', '').replace('_', '/') : 'unknown';
+
+		return {
+			...serverOp,
+			endpoint: endpoint,
+			method: 'POST',
+			headers: { ...this.headers },
+		};
+	}
+
 	/****************************************************************************
 	 SUBSCRIPTION
 	 ****************************************************************************/
diff --git a/assets/js/concise/TaxonomyCreator.js b/assets/js/concise/TaxonomyCreator.js
index 4a5d595..922c3a4 100644
--- a/assets/js/concise/TaxonomyCreator.js
+++ b/assets/js/concise/TaxonomyCreator.js
@@ -82,21 +82,22 @@
 		if (!data.name || data.name.length < 2) return false;
 		try {
 			const response = await this.createTerm(data);
-
+			let currentField = this.selector.currentField();
 			if (!response.success) {
 				// Term already exists - still add it
 				if (response.term && response.term.id) {
-					this.selector.setMessage(true, `Using existing "${response.term.name}"`);
+					this.selector.setMessage(currentField,true, `Using existing "${response.term.name}"`);
 					return response.term;
 				}
 
 				// Other failure
-				this.selector.setMessage(true, response.message || 'Creation failed', false);
+				this.selector.setMessage(currentField,true, response.message || 'Creation failed', false);
 				return false;
 			}
 			if (response.term?.pending) {
 				// Term requires approval
 				this.selector.setMessage(
+					currentField,
 					true,
 					`"${data.name}" submitted for approval`,
 					false
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index 2f58c05..222545d 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -239,6 +239,13 @@
 		const field = this.fields.get(fieldId);
 		if (!fieldId || !field) return;
 
+		if (this.creator) {
+			let button = window.targetCheck(e, this.selectors.create.button);
+			if (button) {
+				this.maybeCreateTerm(e).then(()=>{});
+			}
+		}
+
 		const autocomplete = window.targetCheck(e, '.item.autocomplete');
 
 		if (autocomplete) {
@@ -320,14 +327,6 @@
 				this.ui.search.input.value = '';
 			}
 		}
-
-		if (this.creator) {
-			let button = window.targetCheck(e, this.selectors.create.button);
-			if (button) {
-				this.maybeCreateTerm(e).then(()=>{});
-			}
-		}
-
 	}
 	handleChange(e) {
 		if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
@@ -365,7 +364,7 @@
 		}
 
 		let query = e.target.value.trim();
-		this.setMessage(true, `Searching for "${query}" in ${field.plural??'items'}`);
+		this.setMessage(field,true, `Searching for "${query}" in ${field.plural??'items'}`);
 		window.debouncer.schedule(
 			`${fieldId}-search`,
 			async () => {
@@ -390,7 +389,7 @@
 			return;
 		}
 		this.activeField = fieldId;
-		this.setMessage(true, `Loading ${field.plural}...`);
+		this.setMessage(field,true, `Loading ${field.plural}...`);
 		this.resetFilters({taxonomy: field.taxonomy});
 	}
 
@@ -899,7 +898,7 @@
 			if (this.store.filters.page??1 === 1) {
 				window.removeChildren(this.ui.terms.list);
 			}
-			this.setMessage(true, this.store.filters.search === ''
+			this.setMessage(field,true, this.store.filters.search === ''
 				? `No matching ${field.plural}.`
 				: `No ${field.plural} found.`,
 				false);
@@ -909,7 +908,7 @@
 			return;
 		}
 
-		this.setCreateButton(true);
+		this.setCreateButton(field,true);
 
 		if (this.ui.terms.sentinel) {
 			if (this.store.lastResponse?.has_more) {
@@ -930,7 +929,7 @@
 		).then(()=>{});
 
 		if (terms.length > 0) {
-			this.setMessage(false);
+			this.setMessage(field,false);
 		}
 	}
 	createTermElement(term) {
@@ -946,7 +945,7 @@
 
 		window.removeChildren(dropdown);
 		if (terms.length === 0) {
-			this.setMessage(true, `No ${field.plural} found.`, false);
+			this.setMessage(field,true, `No ${field.plural} found.`, false);
 		} else {
 			window.chunkIt(
 				terms,
@@ -954,9 +953,9 @@
 				(fragment) => dropdown.append(fragment)
 			).then(()=>{});
 
-			this.setMessage(false);
+			this.setMessage(field,false);
 		}
-		this.setCreateButton(true);
+		this.setCreateButton(field,true);
 
 		if (field.ui.dropdown.wrapper) {
 			field.ui.dropdown.wrapper.hidden = false;
@@ -1084,7 +1083,6 @@
 			handlers[event]?.(data);
 		} catch (error) {
 			console.error(`Error handling store event "${event}":`, error);
-			this.setMessage(true, 'An error occurred loading data', false);
 		}
 	}
 	handleDataLoaded() {
@@ -1103,11 +1101,9 @@
 			this.showResults(true);
 			return;
 		}
-		this.setMessage(false);
 	}
 
 	showResults(isAutoComplete = false) {
-		this.setMessage(false);
 		const terms = this.store.getFiltered();
 		const filters = this.store.filters;
 		const isSearch = filters.search && filters.search.length > 0;
@@ -1120,7 +1116,7 @@
 		if (!this.activeField && isAutoComplete) {
 			return;
 		}
-
+		this.setMessage(this.currentField(), false);
 		if (isAutoComplete) {
 			this.showAutocompleteTerms();
 		} else {
@@ -1140,7 +1136,7 @@
 			? `Failed to load ${field.plural}`
 			: 'Failed to load data';
 
-		this.setMessage(true, message, false);
+		this.setMessage(field,true, message, false);
 		console.error('Store fetch error:', error);
 	}
 	async batchFetchTaxonomies() {
@@ -1171,14 +1167,14 @@
 	/**************************************************
 	 LOADING
 	**************************************************/
-	setCreateButton(show = true) {
-		const field = this.currentField();
-		if (!field || !field.canCreate || !this.creator) return;
+	setCreateButton(field, show = true) {
+		if (!field.canCreate || !this.creator) return;
 
 		const conf = (this.container.open) ? this.ui : field.ui;
 		if (!conf.create?.button || !conf.create?.span) return;
 
 		const createButton = conf.create.button;
+		createButton.hidden = !show;
 		const buttonSpan = conf.create.span;
 		const input = (this.container.open) ? conf.search.input : conf.search;
 		if (!input) return;
@@ -1212,8 +1208,8 @@
 		}
 
 		if (data.parent !== undefined && data.name) {
-			this.setMessage(true, `Creating "${data.name}"...`);
-			this.setCreateButton(false);
+			this.setMessage(field,true, `Creating "${data.name}"...`);
+			this.setCreateButton(field,false);
 
 			if (this.container.open) {
 				window.removeChildren(this.ui.terms.list);
@@ -1228,7 +1224,7 @@
 
 			if (term) {
 				// Stop any typeLoop animation and show success message WITHOUT typeLoop
-				this.setMessage(true, `"${term.name}" created!`, false);
+				this.setMessage(field,true, `"${term.name}" created!`, false);
 
 				this.addSelected(term.id, field.id);
 				this.updateFieldValue(field.id);
@@ -1242,10 +1238,10 @@
 					}
 				}
 				this.scheduleHideDropdown(field.id, 300);
-				this.setMessage(false);
+				this.setMessage(field,false);
 			} else {
 				// Creation failed - hide immediately
-				this.setMessage(false);
+				this.setMessage(field,false);
 				if (!this.container.open && field.ui.dropdown.wrapper) {
 					field.ui.dropdown.wrapper.hidden = true;
 				}
@@ -1257,10 +1253,7 @@
 			}
 		}
 	}
-	setMessage(show = true, message = '', type = true) {
-		const field = this.currentField();
-		if (!field) return;
-
+	setMessage(field, show = true, message = '', type = true) {
 		const conf = this.container.open||field.isFilter ? this.ui : (field.isFilter ? null : field.ui);
 		if (!conf?.message?.message) return;
 
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index e5f8643..0d57012 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -10,6 +10,7 @@
 		this.initStores();
 		this.initWorker();
 
+
 		//Maps for DOM references
 		this.fields = new Map();
 		this.uploads = new Map();
@@ -138,7 +139,8 @@
 
 				if (manyRefs.inputs) {
 					for (let input of manyRefs.inputs) {
-						window.prefixInput(input, `${data.id??data.uploadId}-`);
+						let wrapper = input.closest('[data-field]')??el;
+						window.prefixInput(input, `${data.id??data.uploadId}-`, wrapper);
 					}
 				}
 			}
@@ -154,7 +156,8 @@
 			setup({el, refs, manyRefs, data}) {
 				el.dataset.groupId = data.groupId;
 				if (refs.selectAll) {
-					window.prefixInput(refs.selectAll, `select-all-${data.groupId}`, true);
+					let wrapper = refs.selectAll.closest('.field');
+					window.prefixInput(refs.selectAll, `select-all-${data.groupId}`, wrapper,true);
 				}
 				let fields = T.create('groupMetadata', {groupId: data.groupId});
 				if (fields) {
@@ -175,7 +178,8 @@
 			setup({el, refs, manyRefs, data}) {
 				if (refs.inputs) {
 					refs.inputs.forEach(input => {
-						window.prefixInput(input, `${data.groupId}-`);
+						let wrapper = input.closest('[data-field]');
+						window.prefixInput(input, `${data.groupId}-`, wrapper);
 					});
 				}
 			}
@@ -535,6 +539,7 @@
 
 		// Capture values immediately (before debouncer)
 		const inputName = input.name;
+		if (!inputName) return;
 		const inputValue = input.value;
 
 		// Extract the field name from the input name
@@ -663,10 +668,15 @@
 			await this.setBulkUpload(uploads, 'status', 'uploading');
 			await this.setBulkGroup(fieldId, 'operationId', operationId);
 			this.fields.set(field.id, field);
+
+
+			this.notify('sent-to-queue', {
+				field: field,
+				operation: operationId,
+			});
 		} else {
 			await this.setBulkUpload(uploads, 'status', 'failed');
 		}
-		this.notify('sent-to-queue', fieldId);
 		return operationId;
 	}
 
@@ -707,7 +717,12 @@
 		let uploadMap = [];
 		let files = [];
 
-		for (const group of groups) {
+		const validGroups = groups.filter(group => {
+			const groupUploads = this.getGroupUploadsInOrder(group);
+			return groupUploads.length > 0 && groupUploads.some(u => this.formatFile(u));
+		});
+
+		for (const group of validGroups) {
 			const groupElement = this.groups.get(group.id)?.element;
 			const fields = this.collectGroupFieldsFromDOM(groupElement, group.id);
 
@@ -716,7 +731,6 @@
 				fields: fields
 			};
 
-			// Use helper to get uploads in stored order
 			const groupUploads = this.getGroupUploadsInOrder(group);
 
 			for (const upload of groupUploads) {
@@ -738,7 +752,10 @@
 					uploadMap.push(upload.id);
 				}
 			}
-			posts.push(post);
+
+			if (post.images.length > 0) {
+				posts.push(post);
+			}
 		}
 
 		// Handle remaining uploads not in any group
@@ -759,7 +776,10 @@
 				post.images.push(imageData);
 				uploadMap.push(upload.id);
 			}
-			posts.push(post);
+
+			if (post.images.length > 0) {
+				posts.push(post);
+			}
 		}
 
 		return {posts, uploadMap, files};
@@ -1167,6 +1187,13 @@
 	*************************************************************/
 	async checkRecovery() {
 		const pendingUploads = this.stores.uploads.filterByIndex({status: ['local_processing', 'queued', 'uploading']});
+		const allGroups = Array.from(this.stores.groups.data.values());
+		for (const group of allGroups) {
+			const hasUploads = this.stores.uploads.filterByIndex({group: group.id}).length > 0;
+			if (!hasUploads) {
+				await this.stores.groups.delete(group.id);
+			}
+		}
 		if (pendingUploads.length === 0) return;
 
 		// Group by source page
@@ -1746,6 +1773,7 @@
 			avoidImplicitDeselect: true,
 			group: { name: fieldId, pull: true, put: true },
 			dragClass: 'dragging',
+			ignore: '.empty-group',
 
 			onStart: (evt) => {
 				// Get the dragged item's ID
@@ -1777,6 +1805,7 @@
 
 		emptyZone.addEventListener('dragover', (e) => {
 			e.preventDefault();
+			e.stopPropagation();
 			e.dataTransfer.dropEffect = 'move';
 			emptyZone.classList.add('drag-over');
 		});
@@ -1789,6 +1818,7 @@
 
 		emptyZone.addEventListener('drop', async (e) => {
 			e.preventDefault();
+			e.stopPropagation();
 			emptyZone.classList.remove('drag-over');
 
 			// Get selected items from our tracking
diff --git a/assets/js/concise/UtilityFunctions.js b/assets/js/concise/UtilityFunctions.js
index df3c75d..27944aa 100644
--- a/assets/js/concise/UtilityFunctions.js
+++ b/assets/js/concise/UtilityFunctions.js
@@ -413,27 +413,34 @@
 	}
 }
 
-window.prefixInput = function(input, prefix, replace = false) {
+window.prefixInput = function(input, prefix, wrapper = null, replace = false) {
 	if (!input) {
 		console.warn('prefixInput called with null/undefined input');
 		return;
 	}
-	let newId = replace ? prefix : `${prefix}${input.name}`;
-	if (input.labels && input.labels.length > 0) {
-		input.labels?.forEach(label => {
-			label.htmlFor = newId;
-		});
-	} else if (input.previousElementSibling?.tagName === 'label') {
-		let label = input.previousElementSibling;
-		if (label) label.htmlFor = newId;
-	} else if (input.nextElementSibling?.tagName === 'label') {
-		let label = input.nextElementSibling;
-		if (label) label.htmlFor = newId;
-	}else {
-		let label = input.closest('[data-field]')?.querySelector(`label[for="${input.id}"]`);
-		if (label) {
-			label.htmlFor = newId;
-		}
+	const oldId = input.id;
+	const newId = replace ? prefix : `${prefix}${input.name}`;
+
+	// Search for label within wrapper if provided, otherwise use existing logic
+	let label = null;
+
+	if (wrapper) {
+		// Most reliable: search within wrapper by old ID
+		label = wrapper.querySelector(`label[for="${oldId}"]`);
+	} else if (input.labels && input.labels.length > 0) {
+		// Fallback to input.labels if no wrapper provided
+		label = input.labels[0];
+	} else if (input.previousElementSibling?.tagName === 'LABEL') {
+		label = input.previousElementSibling;
+	} else if (input.nextElementSibling?.tagName === 'LABEL') {
+		label = input.nextElementSibling;
+	} else {
+		// Final fallback: search up the tree
+		label = input.closest('[data-field]')?.querySelector(`label[for="${oldId}"]`);
+	}
+
+	if (label) {
+		label.htmlFor = newId;
 	}
 
 	input.id = newId;
diff --git a/assets/js/min/creator.min.js b/assets/js/min/creator.min.js
index 23ecd8d..794be84 100644
--- a/assets/js/min/creator.min.js
+++ b/assets/js/min/creator.min.js
@@ -1 +1 @@
-window.jvbTaxCreator=class{constructor(e){this.selector=e,this.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-term",parent:"#select_parent",summary:".create-term summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}handleOpen(e){this.field=e,this.ui.details&&(this.ui.details.hidden=!e.canCreate,this.ui.summary&&(this.ui.summary.textContent=`Add new ${e.singular}`),this.ui.label.name&&(this.ui.label.name.textContent=`Name this ${e.singular}`),this.ui.label.parent&&(this.ui.label.parent.textContent="Nest it under"))}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}handleClick(e){if(window.targetCheck(e,this.selectors.summary))return this.ui.details.open&&this.ui.name?.focus(),void this.resetParentOptions()}async handleTermCreation(e){if(!e.name||e.name.length<2)return!1;try{const t=await this.createTerm(e);return t.success?t.term?.pending?(this.selector.setMessage(!0,`"${e.name}" submitted for approval`,!1),!1):(t.success&&t.term&&(await this.handleSuccessfulCreation(t.term,e),this.clearForm()),t.term):t.term&&t.term.id?(this.selector.setMessage(!0,`Using existing "${t.term.name}"`),t.term):(this.selector.setMessage(!0,t.message||"Creation failed",!1),!1)}catch(e){return console.error("Error creating term:",e),!1}}async handleSuccessfulCreation(e,t){const i={id:e.id,name:e.name,path:e.path||e.name,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-"),parent:t.parent||0,taxonomy:t.taxonomy,count:0,hasChildren:!1};this.selector.store.data.set(e.id,i),this.ui.details&&(this.ui.details.open=!1),this.selector.store.clearCache(),this.selector.store.fetch().catch((e=>{console.warn("Background fetch after term creation failed:",e)}))}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let i=this.ui.parent.querySelector("option");if(!i)return;window.removeChildren(this.ui.parent),this.ui.parent.append(i.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const e=this.selector.store.get(r);if(e){let t=i.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const s=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===r&&s.push(e)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let t=i.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent="  — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value=""),this.selector.ui.search.input&&(this.selector.ui.search.input.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.hidden=!0)}};
\ No newline at end of file
+window.jvbTaxCreator=class{constructor(e){this.selector=e,this.queue=window.jvbQueue,this.initElements(),this.initListeners()}initElements(){this.selectors={details:"details.create-term",parent:"#select_parent",summary:".create-term summary",suggestion:".term-suggestions",name:"#term_name",button:".submit-term",form:"form.create-term",label:{name:'[for="term_name"]',parent:'[for="select_parent"]'},loading:".loading-message.create-term"},this.ui=window.uiFromSelectors(this.selectors,this.selector.container)}handleOpen(e){this.field=e,this.ui.details&&(this.ui.details.hidden=!e.canCreate,this.ui.summary&&(this.ui.summary.textContent=`Add new ${e.singular}`),this.ui.label.name&&(this.ui.label.name.textContent=`Name this ${e.singular}`),this.ui.label.parent&&(this.ui.label.parent.textContent="Nest it under"))}initListeners(){this.clickHandler=this.handleClick.bind(this),document.addEventListener("click",this.clickHandler),this.ui.form&&this.ui.form.addEventListener("change",(e=>{e.preventDefault(),e.stopPropagation()}))}handleClick(e){if(window.targetCheck(e,this.selectors.summary))return this.ui.details.open&&this.ui.name?.focus(),void this.resetParentOptions()}async handleTermCreation(e){if(!e.name||e.name.length<2)return!1;try{const t=await this.createTerm(e);let i=this.selector.currentField();return t.success?t.term?.pending?(this.selector.setMessage(i,!0,`"${e.name}" submitted for approval`,!1),!1):(t.success&&t.term&&(await this.handleSuccessfulCreation(t.term,e),this.clearForm()),t.term):t.term&&t.term.id?(this.selector.setMessage(i,!0,`Using existing "${t.term.name}"`),t.term):(this.selector.setMessage(i,!0,t.message||"Creation failed",!1),!1)}catch(e){return console.error("Error creating term:",e),!1}}async handleSuccessfulCreation(e,t){const i={id:e.id,name:e.name,path:e.path||e.name,slug:e.slug||e.name.toLowerCase().replace(/\s+/g,"-"),parent:t.parent||0,taxonomy:t.taxonomy,count:0,hasChildren:!1};this.selector.store.data.set(e.id,i),this.ui.details&&(this.ui.details.open=!1),this.selector.store.clearCache(),this.selector.store.fetch().catch((e=>{console.warn("Background fetch after term creation failed:",e)}))}resetParentOptions(){const e=this.selector.currentField();if(!e)return;const t=e.taxonomy;if(!t)return;if(!this.ui.parent)return;let i=this.ui.parent.querySelector("option");if(!i)return;window.removeChildren(this.ui.parent),this.ui.parent.append(i.cloneNode(!0));const r=this.selector.store.filters.parent||0;if(0!==r){const e=this.selector.store.get(r);if(e){let t=i.cloneNode(!0);t.value=e.id,t.textContent=e.name,this.ui.parent.append(t)}}const s=[];this.selector.store.getFiltered().forEach((e=>{e.taxonomy===t&&e.parent===r&&s.push(e)})),s.sort(((e,t)=>e.name.localeCompare(t.name))),s.forEach((e=>{let t=i.cloneNode(!0);t.id=`select-parent-${e.id}`,t.value=e.id,t.textContent="  — "+e.name,this.ui.parent.append(t)}))}async createTerm(e){if(e.name&&void 0!==e.parent&&e.taxonomy)try{const t=await fetch(`${jvbSettings.api}terms`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify(e)});if(!t.ok)throw new Error(`Server error: ${t.status}`);return await t.json()}catch(e){throw console.error("Error creating term:",e),e}}clearForm(){this.ui.name&&(this.ui.name.value=""),this.selector.ui.search.input&&(this.selector.ui.search.input.value="")}destroy(){this.clickHandler&&document.removeEventListener("click",this.clickHandler),this.ui.loading&&(this.ui.loading.hidden=!0)}};
\ No newline at end of file
diff --git a/assets/js/min/crud.min.js b/assets/js/min/crud.min.js
index 2ad3e61..8ac70f0 100644
--- a/assets/js/min/crud.min.js
+++ b/assets/js/min/crud.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,i=(e,i,s)=>{e.dataset.itemId=s.id,window.prefixInput(i.checkbox,`select-${s.id}`,!0),i.checkbox.value=s.id,i.checkbox.checked=t.selected.has(parseInt(s.id)),i.selectLabel&&(i.selectLabel.htmlFor=`select-${s.id}`),i.edit&&(i.edit.dataset.id=s.id),i.trash&&(i.trash.dataset.id=s.id)},s=function(e,t,i){if(i?.fields?.post_thumbnail){const e=i.images[i.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??i.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:s,manyRefs:a,data:l}){if(i(e,s,l),a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)s.sharedRow&&(s.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${l.id}-`)})),t.populate.populate(s.sharedRow,l),s.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),s.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([i,a],n)=>{const o=s.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{window.prefixInput(e,`${a.id}-`)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,s.point)})),s.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{window.prefixInput(e,`${l.id}-`)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let i=e[t.dataset.field],s=e.children[0];s&&(s.textContent="date"===t.dataset.field?window.formatTimeAgo(i):i)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:i,data:s}){t.checkbox&&(t.checkbox.id=`bulk_${s.id}`,t.checkbox.value=s.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=s?.images[s?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection()}})))}initStore(e){let t={...this.defaults,...e};const i=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=i.changes,this.store=i[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"content"===t.endpoint&&Object.keys(t.data?.posts??{}).length>0){this.store.clearCache();let e=Object.keys(t.data.posts),i=this.changesStore.getMany(e);this.changesStore.deleteMany(e);for(let s of e){let e=i.filter((e=>e.id===s))[0]??!1,a=t.data.posts[s],l={};for(let[t,i]of Object.entries(a))e&&!Object.hasOwn(e,t)||(e[t]===i&&delete e[t],l[t]=i);Object.keys(l).length>0&&(l.id=s,l.content=this.content,this.changes.set(s,l))}Object.values(this.changes).length>0&&this.scheduleBackup()}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,i])=>{const s=`tax_${t}`,a=this.cache.get(s);a&&(i.value=a,e[s]=a)}));let i=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===i&&(this.ui.table.nav.checked=!0);let s={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(s))if(t.element){let i=this.cache.get(e)??t.default;t.element.open="open"===i,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let i=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?i="Saving your edits...":t.classList.contains("create")&&(i=`Creating your new ${this.singular}`),this.cancelBackup(),this.handleBackup().then((()=>{})),this.savePosts(i,!1).then((()=>{}))}handleChange(e){const t=e.target.closest("[data-item-id]"),i=e.target.matches("[data-filter]"),s=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||i||s||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(s)this.handleBulkAction(e.target);else if(i)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],i=t.dataset.taxonomy,s=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(i,s,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const i=this.store.get(t);if(!i)return;const s=(i.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...s,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&(t.dataset.itemId.split(",").forEach((t=>{let i=this.forms.getField(e.target).dataset.field,s=this.forms.getFieldValue(e.target);this.updateItem(t,i,s)})),this.savePosts("",!0).then((()=>{})))}updateItem(e,t,i){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=i,this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){await this.changesStore.saveMany(this.changes),this.changes.clear()}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,i]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,i);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),i=`${e}-${t}-01`,s=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(s).padStart(2,"0")}`;this.setFilter("dateFrom",i),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),i=`${this.content}-search`;0!==t.length?window.debouncer.schedule(i,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),i=e.target.closest("tr");if(!t||!i)return;const s=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(i,a);l||(l=this.wrapToRow(i,a)),l&&this.focusFieldInRow(l,s,a)}}findNextEditableRow(e,t=!1){let i=t?e.previousElementSibling:e.nextElementSibling;for(;i&&!this.isEditableRow(i);)i=t?i.previousElementSibling:i.nextElementSibling;return i}wrapToRow(e,t=!1){if(this.isTimeline){const i=e.closest("tbody");if(!i)return null;const s=Array.from(i.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?s[s.length-1]:s[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,i=!1){const s=e.querySelector(`[data-field="${t}"]`);if(!s)return;const a=this.findFocusableInput(s);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=i?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const i of t){const t=e.querySelector(i);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let i=this.store.get(parseInt(t));if(i)return e.push(i.id),window.jvbTemplates.create("bulkItem",i)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const i=await this.changesStore.getAll();if(0===i.length)return;""===e&&(e=`Saving ${i.length} ${1===i.length?this.singular:this.plural}`);let s={},a=[];i.forEach((e=>{let t=e.id;const{id:i,...l}=e;s[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:s},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i=[];if(this.selected.forEach((t=>{i.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(i),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${i.length} ${1===i.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),i=this.status;"trash"===i&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===i||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,i]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,i);let s=this.findFilterEl(t);this.setElValue(s,i)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let i=this.findFilterEl(e,t);this.setElValue(i,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let i=this.findFilterEl(e,t);this.setElValue(i,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),i=this.ui.filters.taxonomies?.[t];return i||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let i=this.ui.filters[e];if("object"==typeof i){if(!Object.hasOwn(this.ui.filters[e],t))return!1;i=this.ui.filters[e][t]}return i}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,i=(e,i,s)=>{e.dataset.itemId=s.id;let a=i.checkbox.closest(".preview");window.prefixInput(i.checkbox,`select-${s.id}`,a,!0),i.checkbox.value=s.id,i.checkbox.checked=t.selected.has(parseInt(s.id)),i.selectLabel&&(i.selectLabel.htmlFor=`select-${s.id}`),i.edit&&(i.edit.dataset.id=s.id),i.trash&&(i.trash.dataset.id=s.id)},s=function(e,t,i){if(i?.fields?.post_thumbnail){const e=i.images[i.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??i.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){i(e,t,l),s(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:s,manyRefs:a,data:l}){if(i(e,s,l),a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)s.sharedRow&&(s.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),t.populate.populate(s.sharedRow,l),s.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),s.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([i,a],n)=>{const o=s.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${a.id}-`,t)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const r=l.images?.[a.post_thumbnail];r&&o.querySelector(".field.upload")?.setAttribute("title",r["image-title"]??""),e.insertBefore(o,s.point)})),s.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let i=e[t.dataset.field],s=e.children[0];s&&(s.textContent="date"===t.dataset.field?window.formatTimeAgo(i):i)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:i,data:s}){t.checkbox&&(t.checkbox.id=`bulk_${s.id}`,t.checkbox.value=s.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=s?.images[s?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,i)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection()}})))}initStore(e){let t={...this.defaults,...e};const i=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=i.changes,this.store=i[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"uploads/groups"===t.endpoint&&(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache()),"operation-status"===e&&"completed"===t.status&&"content_update"===t.type){if(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache(),!t.result||!t.result.posts)return void console.warn("Content update completed but no result.posts",t);const e=Object.keys(t.result.posts).filter((e=>!0===t.result.posts[e]?.success));if(0===e.length)return;this.changesStore.deleteMany(e),e.forEach((e=>this.changes.delete(e)))}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,i])=>{const s=`tax_${t}`,a=this.cache.get(s);a&&(i.value=a,e[s]=a)}));let i=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===i&&(this.ui.table.nav.checked=!0);let s={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(s))if(t.element){let i=this.cache.get(e)??t.default;t.element.open="open"===i,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let i=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?i="Saving your edits...":t.classList.contains("create")&&(i=`Creating your new ${this.singular}`),this.cancelBackup(),this.handleBackup().then((()=>{})),this.savePosts(i,!1).then((()=>{}))}handleChange(e){const t=e.target.closest("[data-item-id]"),i=e.target.matches("[data-filter]"),s=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||i||s||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(s)this.handleBulkAction(e.target);else if(i)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],i=t.dataset.taxonomy,s=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(i,s,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const i=this.store.get(t);if(!i)return;const s=(i.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...s,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&(t.dataset.itemId.split(",").forEach((t=>{let i=this.forms.getField(e.target).dataset.field,s=this.forms.getFieldValue(e.target);this.updateItem(t,i,s)})),this.savePosts("",!0).then((()=>{})))}updateItem(e,t,i){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=i,this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){await this.changesStore.saveMany(this.changes),this.changes.clear()}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,i]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,i);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),i=`${e}-${t}-01`,s=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(s).padStart(2,"0")}`;this.setFilter("dateFrom",i),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),i=`${this.content}-search`;0!==t.length?window.debouncer.schedule(i,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),i=e.target.closest("tr");if(!t||!i)return;const s=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(i,a);l||(l=this.wrapToRow(i,a)),l&&this.focusFieldInRow(l,s,a)}}findNextEditableRow(e,t=!1){let i=t?e.previousElementSibling:e.nextElementSibling;for(;i&&!this.isEditableRow(i);)i=t?i.previousElementSibling:i.nextElementSibling;return i}wrapToRow(e,t=!1){if(this.isTimeline){const i=e.closest("tbody");if(!i)return null;const s=Array.from(i.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?s[s.length-1]:s[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,i=!1){const s=e.querySelector(`[data-field="${t}"]`);if(!s)return;const a=this.findFocusableInput(s);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=i?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const i of t){const t=e.querySelector(i);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let i=this.store.get(parseInt(t));if(i)return e.push(i.id),window.jvbTemplates.create("bulkItem",i)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const i=await this.changesStore.getAll();if(0===i.length)return;""===e&&(e=`Saving ${i.length} ${1===i.length?this.singular:this.plural}`);let s={},a=[];i.forEach((e=>{let t=e.id;const{id:i,...l}=e;s[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:s},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,i=[];if(this.selected.forEach((t=>{i.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(i),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${i.length} ${1===i.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),i=this.status;"trash"===i&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===i||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,i]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,i);let s=this.findFilterEl(t);this.setElValue(s,i)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let i=this.findFilterEl(e,t);this.setElValue(i,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let i=this.findFilterEl(e,t);this.setElValue(i,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),i=this.ui.filters.taxonomies?.[t];return i||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let i=this.ui.filters[e];if("object"==typeof i){if(!Object.hasOwn(this.ui.filters[e],t))return!1;i=this.ui.filters[e][t]}return i}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/dataStore.min.js b/assets/js/min/dataStore.min.js
index d5d94d1..35242db 100644
--- a/assets/js/min/dataStore.min.js
+++ b/assets/js/min/dataStore.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.2){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>{const e=o.error||new Error("Transaction failed with unknown error");a(e)};try{h=r(c,o)}catch(e){a(e||new Error("Callback failed with unknown error"))}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL))return this.notify(e,"data-loaded",{cached:!0,items:r.items||[]}),r;t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){if(!("AbortError"===t?.name))throw console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t}),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s,r){const i=this.stores.get(e),a=(t.items||[]).filter((e=>e&&"object"==typeof e)),o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const l={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,l),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(l)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null===e)return{valid:!0,data:null};if(void 0===e)return t?{valid:!1,error:`Undefined value at ${s}`}:{valid:!0,data:void 0};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0===e.data&&null!==a||(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>!(!e||"object"!=typeof e)&&Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items)return this.applyOrdering(r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]),t);const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];for(const[e,s]of Object.entries(t.filters))if(!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;if(t.filters.orderby||t.filters.order){const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.fields?.post_title||e.title||e.name||"").toLowerCase(),a=(t.fields?.post_title||t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||0),a=new Date(t.modified||0);break;default:i=new Date(e.date||0),a=new Date(t.date||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0}))}return e}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t}),this.notify(e,"data-loaded",{cached:!0,items:this.getFiltered(e)});const a=await this.shouldFetchWithFilters(e,t,i);r.config.endpoint&&a?await this.fetch(e):r.config.endpoint&&this.notify(e,"data-loaded")}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){if(e.instance)return e.instance;e.instance=this,this.dbConfig=new Map,this.databases=new Map,this.stores=new Map,this.subscribers=new Map,this.pendingInits=new Map,this.fetchQueue=[],this._initialized=!1,this.body=document.body,this.loading=document.querySelector("dialog.loading"),this.init()}async init(){this._initialized||(this._initialized=!0,"indexedDB"in window||console.warn("IndexedDB not supported"))}register(e,t=[],s=1.25){if(Array.isArray(t)||(t=[t]),0===t.length)return;this.dbConfig.has(e)||this.dbConfig.set(e,{dbName:`jvb_${e}`,version:s,stores:{},_initialized:!1});let r=this.dbConfig.get(e);t.forEach((t=>{if(!t.storeName)throw new Error(`Store config for "${e}" missing storeName`);if(!t.keyPath)throw new Error(`Store "${t.storeName}" requires keyPath`);const s=`${e}_${t.storeName}`,i={config:{dbName:r.dbName,storeName:"items",keyPath:"id",indexes:[],endpoint:null,apiBase:jvbSettings.api,filters:{},ignore:[],required:null,TTL:36e5,useHttpCaching:!0,showLoading:!1,delayFetch:!0,validateData:!0,...t},dbKey:e,storeKey:s,data:new Map,cache:new Map,filters:{...t.filters||{}},isFetching:!1,currentRequest:null,lastResponse:null,_initialized:!1};i.ignoreFilters=new Set(["search","page","per_page","orderby","order","context","source",...i.config.ignore]),i.config.headers={"X-WP-Nonce":window.auth.getNonce(),...i.config.headers},r.stores[t.storeName]=s,this.stores.set(s,i),this.subscribers.has(s)||this.subscribers.set(s,new Set)})),this.initDB(e).catch((t=>{console.error(`Failed to initialize store "${e}":`,t)}));const i={};for(const[e,t]of Object.entries(r.stores))i[e]=this.getStoreAPI(t);return i}getStoreAPI(e){const t={fetch:()=>this.fetch(e),save:t=>this.save(e,t),saveMany:t=>this.saveMany(e,t),delete:t=>this.delete(e,t),deleteMany:t=>this.deleteMany(e,t),get:t=>this.get(e,t),getMany:t=>this.getMany(e,t),getAll:()=>this.getAll(e),getAllByIndex:(t,s)=>this.getAllByIndex(e,t,s),filterByIndex:t=>this.filterByIndex(e,t),getFiltered:()=>this.getFiltered(e),clear:()=>this.clear(e),setFilter:(t,s)=>this.setFilter(e,t,s),setFilters:t=>this.setFilters(e,t),removeFilter:t=>this.removeFilter(e,t),clearFilters:()=>this.clearFilters(e),clearCache:()=>this.clearCache(e),subscribe:t=>this.subscribe(e,t),ensureInitialized:()=>this.ensureStoreInitialized(e),get filters(){return{...t.getStore().filters}},get lastResponse(){return t.getStore().lastResponse},get data(){return t.getStore().data},getStore:()=>this.stores.get(e)};return t}formDataToObject(e){const t={_isFormData:!0,entries:{}};for(const[s,r]of e.entries())r instanceof File||r instanceof Blob||(t.entries[s]?(Array.isArray(t.entries[s])||(t.entries[s]=[t.entries[s]]),t.entries[s].push(r)):t.entries[s]=r);return t}async objectToFormData(e){if(!e._isFormData)return e;const t=new FormData;for(const[s,r]of Object.entries(e.entries))Array.isArray(r)?r.forEach((e=>t.append(s,e))):t.append(s,r);if(window.jvbUploads&&e.entries.upload_ids){const s=JSON.parse(e.entries.upload_ids);for(const e of s){const s=await window.jvbUploads.getBlobData(e);s&&t.append("files[]",s)}}return t}async initDB(e){const t=this.dbConfig.get(e);if(!t||t._initialized)return;if(this.pendingInits.has(e))return this.pendingInits.get(e);const s=this._performDBInit(e);this.pendingInits.set(e,s);try{await s,t._initialized=!0}finally{this.pendingInits.delete(e)}}async _performDBInit(e){const t=this.dbConfig.get(e),{dbName:s,version:r}=t,i=Object.values(t.stores);try{if(!this.databases.has(s)){const e=await this.openDatabase(s,r,(e=>{i.forEach((t=>{let s=this.stores.get(t);s&&this.setupStores(e,s.config)}))}));this.databases.set(s,e)}i.forEach((e=>{let t=this.stores.get(e);t&&(t.db=this.databases.get(s),t._initialized=!0,this.loadStoreDataInBackground(e),this.notify(e,"db-init"))}))}catch(t){throw console.error(`Failed to initialize database for store "${e}":`,t),t}}openDatabase(e,t,s){return new Promise(((r,i)=>{const a=indexedDB.open(e,t);a.onupgradeneeded=e=>{s&&s(e.target.result,e.oldVersion,e.newVersion)},a.onsuccess=e=>r(e.target.result),a.onerror=e=>i(e.target.error),a.onblocked=()=>{console.warn(`Database ${e} blocked. Close other tabs.`)}}))}setupStores(e,t){if(!e.objectStoreNames.contains(t.storeName)){const s=e.createObjectStore(t.storeName,{keyPath:t.keyPath});t.indexes.forEach((e=>{s.createIndex(e.name,e.keyPath||e.name,{unique:e.unique||!1})}))}if(t.endpoint&&!e.objectStoreNames.contains("cache")){e.createObjectStore("cache",{keyPath:"key"}).createIndex("timestamp","timestamp",{unique:!1})}}async loadFromObjectStore(e,t,s){const r=this.stores.get(e);return r?.db&&r.db.objectStoreNames.contains(t)?new Promise((e=>{const i=r.db.transaction([t],"readonly").objectStore(t).getAll();i.onsuccess=t=>{const r=t.target.result||[];r.forEach(s),e(r)},i.onerror=()=>e([])})):[]}loadStoreDataInBackground(e){const t=this.stores.get(e);t?.db&&Promise.all([this.loadFromObjectStore(e,t.config.storeName,(e=>{const s=this.getItemKey(e,t.config.keyPath);t.data.set(s,e)})),this.loadFromObjectStore(e,"cache",(e=>{this.isCacheValid(e,t.config.TTL)&&t.cache.set(e.key,e)}))]).then((()=>{this.notify(e,"data-ready"),t.config.endpoint&&t.config.delayFetch?(this.fetchQueue.push(e),1===this.fetchQueue.length&&this.processFetchQueue()):t.config.endpoint&&!t.config.delayFetch&&("requestIdleCallback"in window?requestIdleCallback((()=>this.fetch(e)),{timeout:2e3}):setTimeout((()=>this.fetch(e)),100))})).catch((t=>{console.error(`Background load error for store "${e}":`,t)}))}async processFetchQueue(){if(0===this.fetchQueue.length)return;const e=this.fetchQueue.shift();if(!this.stores.get(e))return this.processFetchQueue();try{await this.fetch(e)}catch(t){console.error(`Queue fetch error for "${e}":`,t)}this.fetchQueue.length>0&&("requestIdleCallback"in window?requestIdleCallback((()=>this.processFetchQueue()),{timeout:2e3}):setTimeout((()=>this.processFetchQueue()),50))}async ensureStoreInitialized(e){const t=this.stores.get(e);if(!t)throw new Error(`Store "${e}" not registered`);t._initialized||await this.initDB(t.dbKey)}async withTransaction(e,t,s,r){const i=this.stores.get(e);return i?.db?("string"==typeof t&&(t=[t]),new Promise(((e,a)=>{const o=i.db.transaction(t,s),n=t.map((e=>o.objectStore(e))),c=1===n.length?n[0]:n;let h;o.oncomplete=()=>e(h),o.onerror=()=>{const e=o.error||new Error("Transaction failed with unknown error");a(e)};try{h=r(c,o)}catch(e){a(e||new Error("Callback failed with unknown error"))}}))):null}async fetch(e){await this.ensureStoreInitialized(e);const t=this.stores.get(e);if(!t.isFetching){if(t.config.required){if((Array.isArray(t.config.required)?t.config.required:[t.config.required]).some((e=>!t.filters[e]||""===t.filters[e])))return}t.isFetching=!0;try{const s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r&&this.isCacheValid(r,t.config.TTL)){let t=r.items.map((t=>this.get(e,t)));return this.notify(e,"data-loaded",{cached:!0,items:t??[]}),r}t.config.showLoading&&this.setLoading(!0);const i=this.buildFetchUrl(e),a={...t.config.headers};t.config.useHttpCaching&&r&&(r.etag&&(a["If-None-Match"]=r.etag),r.lastModified&&(a["If-Modified-Since"]=r.lastModified));const o=new AbortController;t.currentRequest=o;const n=await fetch(i,{method:"GET",headers:a,signal:o.signal});if(304===n.status)return r?(this.notify(e,"data-loaded",{cached:!0,notModified:!0,items:r.items||[]}),r):(this.notify(e,"data-loaded",{cached:!1,notModified:!0,items:[]}),t.lastResponse={has_more:!1,total:0,pages:1,queue_stats:{}},{items:[]});if(!n.ok)throw new Error(`HTTP ${n.status}: ${n.statusText}`);const c=await n.json();return await this.processFetchedData(e,c,s,n),this.notify(e,"data-loaded",{cached:!1,items:c.items||[]}),c}catch(t){if(!("AbortError"===t?.name))throw console.error(`Fetch error for store "${e}":`,t),this.notify(e,"fetch-error",{error:t}),t}finally{t.isFetching=!1,t.currentRequest=null,t.config.showLoading&&this.setLoading(!1)}}}buildFetchUrl(e){const t=this.stores.get(e),s=new URLSearchParams;Object.entries(t.filters).forEach((([e,t])=>{null!=t&&""!==t&&("object"==typeof t?s.set(e,JSON.stringify(t)):s.set(e,t))}));const r=t.config.apiBase+t.config.endpoint;return s.toString()?`${r}?${s}`:r}async processFetchedData(e,t,s,r){const i=this.stores.get(e),a=(t.items||[]).filter((e=>e&&"object"==typeof e)),o=[];i.db&&a.length>0&&await this.withTransaction(e,i.config.storeName,"readwrite",(t=>{a.forEach((s=>{try{const r=this._saveItem(e,s);o.push(r),t.put(r.processed)}catch(e){console.error("Error processing item:",e)}}))}));const n={key:s,items:a.map((e=>this.getItemKey(e,i.config.keyPath))),timestamp:Date.now(),endpoint:i.config.endpoint,filters:{...i.filters},etag:r.headers.get("ETag"),lastModified:r.headers.get("Last-Modified"),has_more:t.has_more||!1};i.cache.set(s,n),i.db?.objectStoreNames.contains("cache")&&await this.withTransaction(e,"cache","readwrite",(e=>{e.put(n)})),i.lastResponse={...t,has_more:t.has_more||!1,total:t.total||a.length,pages:t.pages||1,queue_stats:t.queue_stats||{}};for(let[t,s]of Object.entries(i.filters))"string"==typeof s&&s.includes(",")&&this.createSplitCacheEntries(e,a,t,i.filters,r);o.forEach((t=>{t.statusChanged&&this.notify(e,"item-saved",{item:t.item,key:t.key,previousItem:t.previousItem})}))}createSplitCacheEntries(e,t,s,r,i){const a=this.stores.get(e);r[s].split(",").map((e=>e.trim())).forEach((t=>{let o={};o[s]=t;const n={...r,[s]:t},c=this.generateCacheKey(n);if(a.cache.has(c))return;let h=this.filterByIndex(e,o).map((e=>this.getItemKey(e,a.config.keyPath)));const l={key:c,items:h,timestamp:Date.now(),endpoint:a.config.endpoint,filters:n,etag:i.headers.get("Etag"),lastModified:i.headers.get("Last-Modified"),has_more:20===h.length};a.cache.set(c,l),a.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.put(l)}))}))}_saveItem(e,t){const s=this.stores.get(e),r=this.processForStorage(t,s.config.validateData);if(!r.valid)throw new Error(`Non-serializable data: ${r.error}`);const i=r.data,a=this.getItemKey(i,s.config.keyPath),o=s.data.get(a);return s.data.set(a,t),{item:t,previousItem:o,key:a,processed:i,statusChanged:o&&o.status!==t.status}}async save(e,t){const s=this.stores.get(e),r=this._saveItem(e,t);return await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.put(r.processed)})),this.notify(e,"item-saved",{item:r.item,key:r.key,previousItem:r.previousItem}),r.key}async saveMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Map?Array.from(t.values()):Array.isArray(t)?t:Object.values(t);if(0===r.length)return[];const i=[];return r.forEach((t=>{const s=this._saveItem(e,t);i.push(s)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{i.forEach((t=>{e.put(t.processed)}))})),this.notify(e,"items-saved",{count:i.length,keys:i.map((e=>e.key))}),i.map((e=>e.key))}processForStorage(e,t=!0,s="root"){if(null===e)return{valid:!0,data:null};if(void 0===e)return t?{valid:!1,error:`Undefined value at ${s}`}:{valid:!0,data:void 0};const r=typeof e;if(["string","number","boolean"].includes(r))return{valid:!0,data:e};if("function"===r)return t?{valid:!1,error:`Function at ${s}`}:{valid:!0,data:void 0};if(e instanceof HTMLElement||void 0!==e.nodeType)return t?{valid:!1,error:`DOM element at ${s}`}:{valid:!0,data:void 0};if(e instanceof FormData)return{valid:!0,data:this.formDataToObject(e)};if(e instanceof Date||e instanceof ArrayBuffer||ArrayBuffer.isView(e)||e instanceof Blob)return{valid:!0,data:e};if(e instanceof Set)return this.processForStorage(Array.from(e),t,s);if(e instanceof Map&&(e=Object.fromEntries(e)),Array.isArray(e)){const r=[];for(let i=0;i<e.length;i++){const a=this.processForStorage(e[i],t,`${s}[${i}]`);if(!a.valid)return a;void 0!==a.data&&r.push(a.data)}return{valid:!0,data:r}}if("object"===r){const r={};for(const[i,a]of Object.entries(e)){const e=this.processForStorage(a,t,`${s}.${i}`);if(!e.valid)return e;void 0===e.data&&null!==a||(r[i]=e.data)}return{valid:!0,data:r}}return t?{valid:!1,error:`Unknown type at ${s}`}:{valid:!0,data:void 0}}async delete(e,t){const s=this.stores.get(e);s.data.delete(t),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{e.delete(t)})),this.notify(e,"item-deleted",{id:t})}async deleteMany(e,t){const s=this.stores.get(e);if(!s)return[];const r=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===r.length?[]:(r.forEach((e=>{s.data.delete(e)})),await this.withTransaction(e,s.config.storeName,"readwrite",(e=>{r.forEach((t=>{e.delete(t)}))})),this.notify(e,"items-deleted",{count:r.length,ids:r}),r)}get(e,t){return this.stores.get(e).data.get(t)}getMany(e,t,s=!0){const r=this.stores.get(e);if(!r)return[];const i=t instanceof Set?Array.from(t):Array.isArray(t)?t:Object.keys(t);return 0===i.length?[]:s?i.reduce(((e,t)=>{const s=r.data.get(t);return s&&e.push(s),e}),[]):i.map((e=>r.data.get(e)??null))}getAll(e){const t=this.stores.get(e);return Array.from(t.data.values())}filterByIndex(e,t){const s=this.stores.get(e);return s?Array.from(s.data.values()).filter((e=>!(!e||"object"!=typeof e)&&Object.entries(t).every((([t,s])=>(Array.isArray(s)?s:[s]).includes(e[t]))))):[]}async getAllByIndex(e,t,s){const r=this.stores.get(e),i=Array.isArray(s)?s:[s];if(r.db&&r.db.objectStoreNames.contains(r.config.storeName))try{const e=r.db.transaction([r.config.storeName],"readonly").objectStore(r.config.storeName);if(e.indexNames.contains(t)){const s=e.index(t);return(await Promise.all(i.map((e=>new Promise(((t,r)=>{const i=s.getAll(e);i.onsuccess=()=>t(i.result||[]),i.onerror=()=>r(i.error)})))))).flat()}}catch(e){console.warn(`Index query failed for "${t}", falling back to filter:`,e)}return Array.from(r.data.values()).filter((e=>i.includes(e[t])))}getFiltered(e){const t=this.stores.get(e),s=this.generateCacheKey(t.filters),r=t.cache.get(s);if(r?.items){const e=r.items.reduce(((e,s)=>{const r=t.data.get(s);return r&&e.push(r),e}),[]);return this.applyOrdering(e,t)}const i=Array.from(t.data.values()),a=t.filters.search?.toLowerCase().trim()||"",o=[];t.filters.taxonomy&&"object"==typeof t.filters.taxonomy&&Object.entries(t.filters.taxonomy).forEach((([e,t])=>{const s=Array.isArray(t)?t:[t];o.push((t=>{if(!t.taxonomies||!t.taxonomies[e])return!1;const r=Object.keys(t.taxonomies[e]).map((e=>parseInt(e)));return s.some((e=>r.includes(parseInt(e))))}))}));for(const[e,s]of Object.entries(t.filters))if("taxonomy"!==e&&!t.ignoreFilters.has(e)&&null!=s&&""!==s&&"all"!==s)if("string"==typeof s&&s.includes(",")){const t=s.split(",").map((e=>e.trim()));o.push((s=>t.includes(String(s[e]))))}else o.push((t=>String(t[e])===String(s)));const n=i.filter((e=>{for(const t of o)if(!t(e))return!1;return!(a&&!this.searchObject(e,a))}));return this.applyOrdering(n,t)}applyOrdering(e,t){if(Array.isArray(e)||(e=Array.from(e)),0===e.length)return e;const s=t.filters.orderby||"date",r=(t.filters.order||"desc").toLowerCase();return["random","rand"].includes(s)||["random","rand"].includes(r)?this.shuffle(e):(e.sort(((e,t)=>{let i,a;switch(s){case"alphabetical":case"title":i=(e.title||e.name||"").toLowerCase(),a=(t.title||t.name||"").toLowerCase();break;case"modified":i=new Date(e.modified||e.date||0),a=new Date(t.modified||t.date||0);break;default:i=new Date(e.date||e.modified||0),a=new Date(t.date||t.modified||0)}return i<a?"asc"===r?-1:1:i>a?"asc"===r?1:-1:0})),e)}shuffle(e){const t=e.slice();for(let e=t.length-1;e>0;e--){const s=Math.floor(Math.random()*(e+1));[t[e],t[s]]=[t[s],t[e]]}return t}searchObject(e,t){if(!e||"object"!=typeof e)return"string"==typeof e&&e.toLowerCase().includes(t);for(const s of Object.values(e))if(null!=s)if("object"!=typeof s){if("string"==typeof s&&s.toLowerCase().includes(t))return!0}else if(this.searchObject(s,t))return!0;return!1}async clear(e){const t=this.stores.get(e);t.data.clear(),t.cache.clear(),await this.withTransaction(e,t.config.storeName,"readwrite",(e=>{e.clear()})),this.notify(e,"data-cleared")}async updateFilters(e,t,s=!1){const r=this.stores.get(e),i={...r.filters};s&&(r.filters={...r.config.filters}),Object.entries(t).forEach((([e,t])=>{null==t||""===t?delete r.filters[e]:r.filters[e]=t})),this.notify(e,"filters-changed",{oldFilters:i,filters:r.filters,updates:t});const a=await this.shouldFetchWithFilters(e,t,i);if(r.config.endpoint&&a)await this.fetch(e);else{const t=this.getFiltered(e);this.notify(e,"data-loaded",{cached:!0,items:t})}}async shouldFetchWithFilters(e,t,s){const r=this.stores.get(e);if(!r.config.endpoint||!r.lastResponse)return!0;if(!1===r.lastResponse.has_more){if(Object.entries(t).every((([e,t])=>(r.ignoreFilters.has(e),!0))))return!1}if("page"in t){const e=t.page,i=s.page||1;if(e>i&&!r.lastResponse.has_more)return r.filters.page=i,!1}if("search"in t){const e=t.search?.trim()||"",i=s.search?.trim()||"";if(!e&&i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}if(e&&e!==i){const e={...r.filters};if(delete e.search,e.page=1,this.hasCompleteData(r,e))return!1}}return!0}hasCompleteData(e,t){const s=this.generateCacheKey(t),r=e.cache.get(s);return!!r&&(!1===r.has_more||!1===e.lastResponse?.has_more)}setFilter(e,t,s){return this.updateFilters(e,{[t]:s})}async setFilters(e,t){const s=this.stores.get(e);if(Object.keys(t).some((e=>s.filters[e]!==t[e]))||Object.keys(s.filters).some((e=>!(e in t)&&t!==s.config.filters)))return this.updateFilters(e,t)}removeFilter(e,t){return this.updateFilters(e,{[t]:null})}clearFilters(e){return this.updateFilters(e,{},!0)}clearCache(e){const t=this.stores.get(e);t.cache.clear(),t.db?.objectStoreNames.contains("cache")&&this.withTransaction(e,"cache","readwrite",(e=>{e.clear()})),this.notify(e,"cache-cleared")}generateCacheKey(e){const t=Object.keys(e).sort().reduce(((t,s)=>(t[s]=e[s],t)),{});return JSON.stringify(t)}isCacheValid(e,t){if(!e||!e.timestamp)return!1;return Date.now()-e.timestamp<t}subscribe(e,t){this.subscribers.has(e)||this.subscribers.set(e,new Set);const s=this.subscribers.get(e);return s.add(t),()=>s.delete(t)}notify(e,t,s={}){const r=this.subscribers.get(e);r&&r.forEach((r=>{try{r(t,s)}catch(t){console.error(`Subscriber error for store "${e}":`,t)}}))}getItemKey(e,t){if("function"==typeof t)return t(e);const s=t.split(".");let r=e;for(const e of s)r=r?.[e];return r}setLoading(e){this.body.classList.toggle("loading",e),e?this.loading?.showModal():this.loading?.close()}destroy(){this.stores.forEach((e=>{e.currentRequest&&e.currentRequest.abort()})),this.databases.forEach((e=>e.close())),this.stores.clear(),this.subscribers.clear(),this.databases.clear(),this.pendingInits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbStore=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/form.min.js b/assets/js/min/form.min.js
index 1c13797..7789077 100644
--- a/assets/js/min/form.min.js
+++ b/assets/js/min/form.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n        <button class="restore" type="button" data-form-id="${e}">Restore</button>\n        <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0){e.preventDefault(),console.log("Cancelling scheduled backup and manually backing up"),this.cancelBackup(),await this.backup();const s=await this.store.get(t.id);t.options.cache?this.notify("form-submit",{config:t,data:s.changes}):this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}})}if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${e.dataset.fieldName}:${r}:`)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){Array.from(e.children).forEach(((t,s)=>{t.dataset.index=`${s}`,Array.from(t.children).forEach((t=>{"hidden"===t.type&&window.prefixInput(t,`${e.dataset.field}:${s}:${t.dataset.field}`)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n        <button class="restore" type="button" data-form-id="${e}">Restore</button>\n        <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),console.log("Cancelling scheduled backup and manually backing up"),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=e.closest("[data-field]");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=window.closest(".tag-item");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/populate.min.js b/assets/js/min/populate.min.js
index ced1583..db023ec 100644
--- a/assets/js/min/populate.min.js
+++ b/assets/js/min/populate.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomyField(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const r=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),r&&(window.removeChildren(r),a.forEach((e=>{let t=this.data.images[e]??{};t.id=e,r.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const r=["image-title","image-alt-text","image-caption"];for(const e of r){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){e.dataset.itemId=l.id,i.select&&window.prefixInput(i.select,`${l.id}-`),i.video&&i.video.remove(),i.file&&i.file.remove();let r=t.data.images[l.post_thumbnail]??!1;if(i.img&&r&&(i.img.src=r.medium||r.small||r.large||"",i.img.title=r["image-title"]??"",i.img.alt=r["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const r=e.querySelector('input:not([type="file"]), textarea');r&&window.prefixInput(r,`[${l.id}]`)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let r=i.querySelectorAll(".field");this.populate(r,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomyField(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const r=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),r&&(window.removeChildren(r),a.forEach((e=>{let t=this.data.images[e]??{};t.id=e,r.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const r=["image-title","image-alt-text","image-caption"];for(const e of r){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let r=t.data.images[l.post_thumbnail]??!1;if(i.img&&r&&(i.img.src=r.medium||r.small||r.large||"",i.img.title=r["image-title"]??"",i.img.alt=r["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const r=e.querySelector('input:not([type="file"])');r&&window.prefixInput(r,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/queue.min.js b/assets/js/min/queue.min.js
index 73c402b..3186a42 100644
--- a/assets/js/min/queue.min.js
+++ b/assets/js/min/queue.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(!this.ui.panel)return;return this.getQueueByStatus(this.pendingStatuses).length>0?(e.preventDefault(),e.returnValue="",""):void 0}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],filters:{user:window.auth.getUser()},showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.previousItem&&t.previousItem.status!==t.item.status&&this.updateOperationStatus(t.item.id,t.item.status),this.maybeStartPolling()}}))}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",window.auth.getUser()),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:window.auth.getUser()}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status))}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.ui.panel&&this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/2)`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel&&(this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e)}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.user=window.auth.getUser(),this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.api=jvbSettings.api,this.endpoint="queue",this.queueItems=new Map,this.init()}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=new window.jvbPopup({popup:this.ui.panel,toggle:this.ui.toggle.button,name:"Queue Panel"})),this.defineTemplates()}initElements(){this.panelStatuses=["syncing","synced","pending","offline"],this.statuses=["queued","localProcessing","uploading","pending","processing","completed","failed","failed_permanent"],this.pendingStatuses=["queued","localProcessing","uploading"],this.workingStatuses=["pending","processing"],this.completedStatuses=["completed","failed","failed_permanent"],this.icons={queued:"arrows-clockwise",localProcessing:"arrows-clockwise",uploading:"syncing",pending:"cloud",processing:"syncing",completed:"cloud-check",failed:"cloud-warning",failed_permanent:"cloud-warning"},this.selectors={panel:"aside#queue",toggle:{button:"button.qtoggle",indicator:".qtoggle .indicator",count:".qtoggle .count"},refresh:{button:"#queue .refresh .refreshNow",countdown:"#queue .refresh .countdown"},popup:{popup:"#queue .popup",message:"#queue .popup span"},items:{container:"#queue .qitems"},actions:{retry:"#queue .retry-all",clear:"#queue .dismiss-all"},filters:{filter:"#queue [data-filter]",all:{label:'#queue [for="qfilter-all"]',radio:'#queue [data-filter="all"]',count:'#queue [data-filter="all"] .count'},queued:{label:'#queue [for="qfilter-queued"]',input:'#queue [data-filter="queued"]',count:'#queue [for="qfilter-queued"] .count'},localProcessing:{label:'#queue [for="qfilter-localProcessing"]',input:'#queue [data-filter="localProcessing"]',count:'#queue [for="qfilter-localProcessing"] .count'},uploading:{label:'#queue [for="qfilter-uploading"]',input:'#queue [data-filter="uploading"]',count:'#queue [for="qfilter-uploading"] .count'},pending:{label:'#queue [for="qfilter-pending"]',input:'#queue [data-filter="pending"]',count:'#queue [for="qfilter-pending"] .count'},processing:{label:'#queue [for="qfilter-processing"]',input:'#queue [data-filter="processing"]',count:'#queue [for="qfilter-processing"] .count'},completed:{label:'#queue [for="qfilter-completed"]',input:'#queue [data-filter="completed"]',count:'#queue [for="qfilter-completed"] .count'},failed:{label:'#queue [for="qfilter-failed"]',input:'#queue [data-filter="failed"]',count:'#queue [for="qfilter-failed"] .count'}},item:{type:".type",status:".status",details:".info .details",icon:".status .icon",startedAt:".started time",completed:{wrap:".completed",label:".completed span",time:".completed time"},progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},actions:{cancel:"button.cancel",retry:"button.retry",refresh:"button.refresh",dismiss:"button.dismiss"}}},this.ui=window.uiFromSelectors(this.selectors),this.ui.panel||(this.canUpdateUI=!1)}defineTemplates(){const e=window.jvbTemplates;e.define("emptyState"),e.define("queueItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id}})}initListeners(){this.activityListeners=null,this.clickHandler=this.handleClick.bind(this),this.onlineHandler=this.handleOnline.bind(this),this.offlineHandler=this.handleOffline.bind(this),this.unloadHandler=this.handleBeforeUnload.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),window.addEventListener("beforeunload",this.unloadHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleBeforeUnload(e){if(!this.ui.panel)return;return this.getQueueByStatus(this.pendingStatuses).length>0?(e.preventDefault(),e.returnValue="",""):void 0}handleClick(e){if(!window.targetCheck(e,this.selectors.panel+", "+this.selectors.toggle.button))return;if(window.targetCheck(e,this.selectors.refresh.button))return this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),this.store.clearFilters(),void this.store.fetch().finally((()=>{this.ui.refresh.button.classList.remove("fetching")}));if(window.targetCheck(e,this.selectors.actions.refresh))return void this.handleRefresh(opId);if(window.targetCheck(e,this.selectors.actions.clear))return void this.opActions("completed","dismiss").then((()=>{}));if(window.targetCheck(e,this.selectors.actions.retry))return void this.opActions("failed","retry").then((()=>{}));const t=window.targetCheck(e,"[data-action]");if(t){const e=t.closest("[data-id]")?.dataset.id;return void(e&&this.opActions(e,t.dataset.action))}const s=window.targetCheck(e,this.selectors.filters.filter);s&&this.setFilter(s.dataset.filter)}setFilter(e){Object.values(this.ui.filters).forEach((t=>{t.input?.dataset.filter===e&&(t.input.checked=!0)})),"all"===e?this.store.clearFilters():this.store.setFilter("status",e)}trackActivity(){if(!this.activityListeners){const e=["mousedown","mousemove","keypress","scroll","touchstart"];this.activityListeners=e.map((e=>{const t=()=>this.resetActivityTimer();return document.addEventListener(e,t,{passive:!0}),{event:e,handler:t}}))}this.resetActivityTimer()}resetActivityTimer(){this.activityTimer&&clearTimeout(this.activityTimer),this.activityTimer=setTimeout((()=>{this.processQueue()}),1750)}stopActivityTracking(){this.activityTimer&&(clearTimeout(this.activityTimer),this.activityTimer=null),this.activityListeners&&(this.activityListeners.forEach((({event:e,handler:t})=>{document.removeEventListener(e,t)})),this.activityListeners=null)}initStore(){if(!this.user)return;const e=window.jvbStore.register("queue",{storeName:"queue",keyPath:"id",endpoint:this.endpoint,TTL:1/0,indexes:[{name:"status",keyPath:"status"},{name:"type",keyPath:"type"}],filters:{user:window.auth.getUser()},showLoading:!1});this.store=e.queue,this.store.subscribe(((e,t)=>{switch(e){case"data-loaded":this.store.getAll().forEach((e=>{const t=this.queue.get(e.id),s=this.mapServerOperation(e);this.queue.set(s.id,s),t&&t.status!==s.status&&this.notify("operation-status",s)})),this.maybeStartPolling(),this.updateUI();break;case"items-save":this.maybeStartPolling(),this.updateUI();break;case"item-saved":t.item&&(this.queue.set(t.item.id,t.item),t.previousItem?.status!==t.item.status&&this.notify("operation-status",t.item)),this.maybeStartPolling()}}))}handleRefresh(e){const t=this.getQueue(e);if(!t)return;let s=null;if(s={content_update:t.data?.posts?Object.values(t.data.posts)[0]?.content:null,batch_creation:t.data?.content,image_upload:"uploads",video_upload:"uploads",document_upload:"uploads"}[t.type],s&&window.jvbStore){if(window.jvbStore.stores.get(s)){window.jvbStore.clearCache(s),window.jvbStore.fetch(s);const t=this.items.get(e)?.ui?.actions?.refresh;if(t){const e=t.querySelector("span").textContent;t.querySelector("span").textContent="Refreshed!",t.disabled=!0,setTimeout((()=>{t.querySelector("span").textContent=e,t.disabled=!1}),2e3)}}}else confirm("Refresh the page to see changes?")&&window.location.reload()}addToQueue(e){const t={id:`u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2,9)}`,endpoint:null,method:"POST",headers:{},data:{},delay:!1,canMerge:!0,popup:"Saving changes...",title:"Operation",status:"queued",timestamp:Date.now(),created_at:(new Date).toISOString(),retries:0,user:this.user,...e};if(t.headers={...this.headers,...t.headers},!t.endpoint||!t.data)return null;if(t.popup&&this.ui.popup?.message&&(this.ui.popup.message.textContent=t.popup,this.ui.popup.popup.hidden=!1,setTimeout((()=>this.ui.popup.popup.hidden=!0),2e3)),!t.delay)return this.queue.set(t.id,t),this.processOperation(t).then((()=>{})),this.store.clearCache(),this.maybeStartPolling(),this.toggleQueue(),t.id;const s=Array.from(this.getAllQueue()).filter((e=>"queued"===e.status&&e.endpoint===t.endpoint&&e.canMerge));if(s.length>0){const e=s[0];return e.data=window.deepMerge(e.data,t.data),e.timestamp=Date.now(),this.setQueue(e),this.updateOperationStatus(e.id,e.status),this.updateUI(),this.trackActivity(),e.id}return this.store.clearCache(),this.setQueue(t),this.updateOperationStatus(t.id,t.status),this.updateUI(),this.trackActivity(),t.id}async opActions(e,t){if(this.statuses.includes(e)?e=this.getQueueByStatus(e).map((e=>e.id)):"string"==typeof e&&(e=[e]),0===e.length)return;if(!["cancel","dismiss","retry"].includes(t))return;const s=["cancel","dismiss"].includes(t);s&&e.forEach((e=>{this.removeOperationUI(e)}));try{const i=await fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:e,user:this.user})});if(!i.ok)throw new Error(`${t} failed: ${i.status}`);const n=await i.json();if(!n.success)throw new Error(n.message||`${t} operation failed`);return e.forEach((e=>{let i=this.getQueue(e);if(i&&this.notify(`${t}-operation`,i),s)this.clearQueue(e);else{let t=this.getQueue(e);t.status="queued",this.setQueue(t),this.updateOperationStatus(t.id,t.status)}})),"retry"===t&&this.trackActivity(),this.updateUI(),n}catch(s){return await window.jvbError.log(s,{component:"Queue",operation:"performQueueAction",action:t,operationIds:e,itemCount:e.length},(()=>this.opActions(e,t))),{success:!1,error:s.message}}}async processQueue(){if(this.isProcessing)return;const e=this.getQueueByStatus("queued");if(0!==e.length){this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1),this.stopActivityTracking(),this.toggleQueue(this.maybeStartPolling())}else this.stopActivityTracking()}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(s=!0,e.data=await this.store.objectToFormData(e.data)),this.updateOperationStatus(e.id,"uploading"),e.data instanceof FormData?(e.data.append("id",e.id),e.data.append("user",window.auth.getUser()),t=e.data):(t=JSON.stringify({...e.data,id:e.id,user:window.auth.getUser()}),e.headers["Content-Type"]="application/json"),null==t)return;const i=await fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),n=await i.json();if(s&&(e.data={}),!i.ok||!n.success)throw new Error(n.message||`HTTP ${i.status}`);n.id&&e.id!==n.id?e=await this.handleServerMerge(e,n):(e.status=n.status??"pending",e.serverData=n,this.updateOperationStatus(e.id,e.status)),this.a11y.announce(`${e.title} sent to server for processing`),this.setQueue(e)}catch(t){console.error("Operation failed: ",t),e.retries++,e.lastError=t.message,e.retries>=3?e.status="failed_permanent":e.status="failed",this.updateOperationStatus(e.id,e.status),this.setQueue(e)}}async handleServerMerge(e,t){const s=this.getQueue(t.id);return s?(e.status=t.status||"pending",e.serverData=t,this.mergeOp(s,e)):(this.clearQueue(e.id),this.setQueue(t),t)}mergeOp(e,t){return e.data=window.deepMerge(e.data,t.data),e.status=t.status,Object.hasOwn(t,"serverData")&&(e.serverData=t.serverData),this.updateOperationStatus(e.id,e.status),this.removeOperationUI(t.id),this.clearQueue(t.id),e}sortByDate(e){return e.sort(((e,t)=>(e.updated_at??e.timestamp??0)-(t.updated_at??t.timestamp??0)))}sortOperations(e){const t={processing:0,uploading:1,pending:2,queued:3,localProcessing:4,failed:5,completed:6,failed_permanent:7};return e.sort(((e,s)=>{const i=(t[e.status]??99)-(t[s.status]??99);if(0!==i)return i;const n=e.updated_at??e.timestamp??0,a=s.updated_at??s.timestamp??0;return new Date(a)-new Date(n)}))}getAllQueue(){let e=[...new Set([...Array.from(this.store.data.values()),...Array.from(this.queue.values())])];return this.sortOperations(e)}getQueueByStatus(e){"string"==typeof e&&(e=[e]);let t=[...new Set([...Array.from(this.store.filterByIndex({status:e})),...Array.from(this.queue.values()).filter((t=>e.includes(t.status)))])];return this.sortOperations(t)}updateOperationStatus(e,t){let s=this.getQueue(e);s&&this.statuses.includes(t)&&(s.status=t,this.notify("operation-status",s),this.setQueue(s))}setQueue(e){this.store.save(e),this.queue.set(e.id,e)}getQueue(e){return this.queue.has(e)?this.queue.get(e):this.store.get(e)}clearQueue(e){this.queue.delete(e),this.store.delete(e)}maybeStartPolling(){return this.getQueueByStatus([...this.pendingStatuses,...this.workingStatuses]).length>0?(this.startPolling(),!0):(this.updatePanel("synced"),!1)}startPolling(){this.isPolling||(this.isPolling=!0,this.updatePanel("pending"),this.runPollCycle())}async runPollCycle(){if(this.isPolling){try{if(this.ui.refresh.button.classList.add("fetching"),this.store.clearCache(),await this.store.fetch(),this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){console.error("Polling error:",e)}this.startCountdown(5,(()=>this.runPollCycle()))}}startCountdown(e,t){this.ui.refresh.countdown?(this.ui.refresh.countdown.classList.add("counting"),this.ui.refresh.countdown.textContent=e,this.countdownTimer=setInterval((()=>{--e>0?this.ui.refresh.countdown.textContent=e:(this.stopCountdown(),t&&t())}),1e3)):console.warn("Countdown element not found")}stopPolling(){this.isPolling&&(this.isPolling=!1,this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.stopCountdown())}stopCountdown(){this.countdownTimer&&(clearInterval(this.countdownTimer),this.countdownTimer=null),this.ui.refresh.countdown.classList.remove("counting"),this.ui.refresh.countdown.textContent=""}updateUI(){this.canUpdateUI&&window.debouncer.schedule("queue-ui",this.handleUpdateUI.bind(this))}handleUpdateUI(){const e=this.getAllQueue();this.ui.actions.retry.disabled=0===e.filter((e=>"failed"===e.status)).length,this.ui.actions.clear.disabled=0===e.filter((e=>"completed"===e.status)).length;const t=e.filter((e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status))).length;this.ui.toggle.count.hidden=0===t,this.ui.toggle.count.textContent=t;for(let t of this.statuses){if("failed_permanent"===t)continue;let s=e.filter((e=>e.status===t)).length;this.ui.filters[t].label.hidden=0===s,this.ui.filters[t].input.dataset.count=`${s}`,this.ui.filters[t].count.textContent=s>0?s:""}this.renderOperations()}renderOperations(){if(!this.ui.items.container)return;const e=this.store.filters?.status??"all",t="all"===e?this.getAllQueue():this.getQueueByStatus(e),s=this.sortOperations(t);if(0===s.length){window.removeChildren(this.ui.items.container);const e=window.jvbTemplates.create("emptyQueue");return this.ui.items.container.append(e),void this.a11y.announce("No items in queue")}this.ui.items.container.querySelector(".empty-group")?.remove();const i=new Set(s.map((e=>e.id)));this.items.forEach(((e,t)=>{i.has(t)||(e.element?.remove(),this.items.delete(t))})),s.forEach(((e,t)=>{let s=this.items.get(e.id);s||(s=this.createOperationElement(e)),s?.element&&(this.updateOperationUI(e.id),this.ui.items.container.append(s.element))}))}createOperationElement(e){const t=window.jvbTemplates.create("queueItem",e),s={element:t,ui:window.uiFromSelectors(this.selectors.item,t)};return this.items.set(e.id,s),s}updateOperationUI(e){let t=this.items.has(e)?this.items.get(e):this.createOperationElement(e);if(!t)return;let s=this.getQueue(e),i=t.element;i.classList.remove(this.statuses),i.classList.add(s.status);let n=this.getProgress(s);t.ui.type&&t.ui.type.textContent!==s.title&&(t.ui.type.textContent=s.title),t.ui.status&&(t.ui.status.title=this.statusLabel(s.status)),t.ui.icon&&(t.ui.icon.className=`icon icon-${this.icons[s.status]}`),t.ui.details&&(t.ui.details.textContent=this.itemMessage(s)),t.ui.startedAt&&(t.ui.startedAt.setAttribute("datetime",s.created_at),t.ui.startedAt.textContent=window.formatTimeAgo(s.created_at));s.status;const a="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!a,a){const e=s.completed_at??s.updated_at;t.ui.completed.label.textContent="Completed: ",t.ui.completed.time.setAttribute("datetime",e),t.ui.completed.time.textContent=window.formatTimeAgo(e)}window.showProgress(t.ui.progress,n,100,this.statusLabel(s.status)),t.ui.actions.cancel&&(t.ui.actions.cancel.hidden=this.completedStatuses.includes(s.status)),t.ui.actions.retry&&(s.retries>=3&&(t.ui.actions.retry.disabled=!0),t.ui.actions.retry.hidden="failed"!==s.status),t.ui.actions.dismiss&&(t.ui.actions.dismiss.hidden=this.pendingStatuses.includes(s.status)),t.ui.actions.refresh&&(t.ui.actions.refresh.hidden="completed"!==s.status)}getProgress(e){if(e.progress)return e.progress;if(!this.statuses.includes(e.status))return 0;return{queued:10,uploading:25,pending:40,processing:70,completed:100,failed:0,failed_permanent:0}[e.status]??0}removeOperationUI(e){let t=this.items.get(e);t&&window.fade(t.element,!1)}updatePanel(e="syncing"){this.ui.panel&&this.panelStatuses.includes(e)&&(this.ui.panel.classList.remove(...this.panelStatuses),this.ui.panel.classList.add(e))}statusLabel(e){if(!this.statuses.includes(e))return"";return{queued:"Queued",localProcessing:"Processing locally",uploading:"Uploading",pending:"Waiting on server",processing:"Processing",completed:"Completed",failed:"Failed",failed_permanent:"Failed permanently"}[e]}itemMessage(e){if(Object.hasOwn(e,"message")&&""!==e.message)return e.message;if(Object.hasOwn(e,"error_message")&&e.error_message)return e.error_message;switch(e.status){case"queued":return"Waiting to send...";case"uploading":return"Sending to server...";case"pending":return e.position?`Position ${e.position} in queue`:"In server queue";case"processing":return e.progress?`${e.progress}% complete`:"Processing...";case"completed":return"Successfully completed. Refresh to see changes.";case"failed":return`Failed: ${e.lastError||"Unknown error"} (Retry ${e.retries}/2)`;case"failed_permanent":return`Failed: ${e.lastError||"Unknown error"}`;default:return""}}toggleQueue(e=!0){this.ui.panel&&(this.ui.panel.hidden=!e,this.ui.toggle.button.hidden=!e)}setProcessing(e=!0){this.isProcessing=e,this.ui.toggle.button.classList.toggle("saving",e)}mapServerOperation(e){const t=this.queue.get(e.id);if(t&&t.endpoint)return{...t,...e,endpoint:t.endpoint,method:t.method,headers:t.headers};const s=e.type?e.type.replace("_update","").replace("_","/"):"unknown";return{...e,endpoint:s,method:"POST",headers:{...this.headers}}}subscribe(e){if(this.subscribers)return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>s(e,t)))}destroy(){this.isPolling&&this.stopPolling(),this.stopActivityTracking(),document.removeEventListener("click",this.clickHandler),this.subscribers.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbQueue=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/selector.min.js b/assets/js/min/selector.min.js
index 92d967a..7c70e08 100644
--- a/assets/js/min/selector.min.js
+++ b/assets/js/min/selector.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(!1)),this.setCreateButton(!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t),this.setMessage(!0,"An error occurred loading data",!1)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField?this.showResults(!0):this.setMessage(!1)}showResults(e=!1){this.setMessage(!1);const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e=!0){const t=this.currentField();if(!t||!t.canCreate||!this.creator)return;const s=this.container.open?this.ui:t.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button,r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=e&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(!0,`Creating "${s.name}"...`),this.setCreateButton(!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(!1)}else this.setMessage(!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e=!0,t="",s=!0){const i=this.currentField();if(!i)return;const r=this.container.open||i.isFilter?this.ui:i.isFilter?null:i.ui;if(!r?.message?.message)return;t=""===t?`No ${i.plural??"items"} found.`:t;const a=r.message.message,n=r.message.text;a.hidden=!e,e?t&&n&&(s&&window.typeLoop&&n?(this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id]),this.messageText[i.id]=window.typeLoop(n,t)):n.textContent=t):this.messageText[i.id]&&(this.messageText[i.id](),delete this.messageText[i.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(s,!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(t,!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(t,!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(t,!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(t,!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(e,!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(e,!1)),this.setCreateButton(e,!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(this.setMessage(this.currentField(),!1),e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(t,!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e,t=!0){if(!e.canCreate||!this.creator)return;const s=this.container.open?this.ui:e.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button;i.hidden=!t;const r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=t&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(t,!0,`Creating "${s.name}"...`),this.setCreateButton(t,!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(t,!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(t,!1)}else this.setMessage(t,!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e,t=!0,s="",i=!0){const r=this.container.open||e.isFilter?this.ui:e.isFilter?null:e.ui;if(!r?.message?.message)return;s=""===s?`No ${e.plural??"items"} found.`:s;const a=r.message.message,n=r.message.text;a.hidden=!t,t?s&&n&&(i&&window.typeLoop&&n?(this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id]),this.messageText[e.id]=window.typeLoop(n,s)):n.textContent=s):this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/uploader.min.js b/assets/js/min/uploader.min.js
index cba5853..268f720 100644
--- a/assets/js/min/uploader.min.js
+++ b/assets/js/min/uploader.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let e of i.inputs)window.prefixInput(e,`${r.id??r.uploadId}-`)}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){t.dataset.groupId=r.groupId,s.selectAll&&window.prefixInput(s.selectAll,`select-all-${r.groupId}`,!0);let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{window.prefixInput(e,`${i.groupId}-`)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name,i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i)):await this.setBulkUpload(r,"status","failed"),this.notify("sent-to-queue",t),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e}),i=[],r=[],a=[];for(const e of s){const t=this.groups.get(e.id)?.element,s={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const i=this.formatFile(t);if(i){a.push(i);const o={upload_id:t.id,index:r.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(s.fields.featured=t.id),s.images.push(o),r.push(t.id)}}i.push(s)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},s=this.formatFile(e);if(s){a.push(s);const i={upload_id:e.id,index:r.length};t.images.push(i),r.push(e.id)}i.push(t)}return{posts:i,uploadMap:r,files:a}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift();i.push(await this.processImage(e,t,s))}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>e.file)));for(let t=0;t<o.length;t++){const{uploadId:s,upload:a}=o[t];a.blob=d[t],a.fields.size=d[t].size,a.status="queued",await this.setUpload(s,a),r++,this.updateFieldProgress(e,r,i,"Processing files...")}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]});if(0===e.length)return;const t=new Map;e.forEach((e=>{const s=e.src||"unknown";t.has(s)||t.set(s,[]),t.get(s).push(e)}));let s={bySource:t,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",s));let i=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(i),this.restoreSelection=new window.jvbHandleSelection(i,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let t of i.inputs){let s=t.closest("[data-field]")??e;window.prefixInput(t,`${r.id??r.uploadId}-`,s)}}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){if(t.dataset.groupId=r.groupId,s.selectAll){let e=s.selectAll.closest(".field");window.prefixInput(s.selectAll,`select-all-${r.groupId}`,e,!0)}let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${i.groupId}-`,t)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name;if(!s)return;const i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i),this.notify("sent-to-queue",{field:i,operation:c})):await this.setBulkUpload(r,"status","failed"),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=[],i=[],r=[];const a=this.stores.groups.filterByIndex({field:e}).filter((e=>{const t=this.getGroupUploadsInOrder(e);return t.length>0&&t.some((e=>this.formatFile(e)))}));for(const e of a){const t=this.groups.get(e.id)?.element,a={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const s=this.formatFile(t);if(s){r.push(s);const o={upload_id:t.id,index:i.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(a.fields.featured=t.id),a.images.push(o),i.push(t.id)}}a.images.length>0&&s.push(a)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},a=this.formatFile(e);if(a){r.push(a);const s={upload_id:e.id,index:i.length};t.images.push(s),i.push(e.id)}t.images.length>0&&s.push(t)}return{posts:s,uploadMap:i,files:r}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift();i.push(await this.processImage(e,t,s))}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>e.file)));for(let t=0;t<o.length;t++){const{uploadId:s,upload:a}=o[t];a.blob=d[t],a.fields.size=d[t].size,a.status="queued",await this.setUpload(s,a),r++,this.updateFieldProgress(e,r,i,"Processing files...")}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]}),t=Array.from(this.stores.groups.data.values());for(const e of t){this.stores.uploads.filterByIndex({group:e.id}).length>0||await this.stores.groups.delete(e.id)}if(0===e.length)return;const s=new Map;e.forEach((e=>{const t=e.src||"unknown";s.has(t)||s.set(t,[]),s.get(t).push(e)}));let i={bySource:s,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",i));let r=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection(r,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",ignore:".empty-group",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.stopPropagation(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),t.stopPropagation(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})();
\ No newline at end of file
diff --git a/assets/js/min/utility.min.js b/assets/js/min/utility.min.js
index 81f4cbe..16ef657 100644
--- a/assets/js/min/utility.min.js
+++ b/assets/js/min/utility.min.js
@@ -1 +1 @@
-(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");let i=n?t:`${t}${e.name}`;if(e.labels&&e.labels.length>0)e.labels?.forEach((e=>{e.htmlFor=i}));else if("label"===e.previousElementSibling?.tagName){let t=e.previousElementSibling;t&&(t.htmlFor=i)}else if("label"===e.nextElementSibling?.tagName){let t=e.nextElementSibling;t&&(t.htmlFor=i)}else{let t=e.closest("[data-field]")?.querySelector(`label[for="${e.id}"]`);t&&(t.htmlFor=i)}e.id=i},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n)})();
\ No newline at end of file
+(()=>{window.fade=function(e,t=!0){t?e.style.animation="fadeIn var(--transition-base)":(e.style.animation="fadeOut var(--transition-base)",window.debouncer.schedule(`remove-${e.dataset.id??e.id??e.className.replace(" ","-")}`,(()=>{e.remove()}),500))},window.formatTimeAgo=function(e,t="default"){const n=e instanceof Date?e:new Date(e),i=n-new Date,o=i<0,r=Math.floor(Math.abs(i)/1e3),a=Math.floor(r/60),s=Math.floor(a/60),l=Math.floor(s/24);if(0===a)return"Just now";let c="";if(r<10)c="a moment";else if(r<60)c="less than a minute";else if(a<5)c="a few minutes";else if(s<24)c=0===s?`${a} ${1===a?"minute":"minutes"}`:`about ${s} ${1===s?"hour":"hours"}`;else{if(!(l<7)){if("default"===t)return n.toLocaleDateString();const e={Y:n.getFullYear(),y:String(n.getFullYear()).slice(-2),F:n.toLocaleDateString("en-CA",{month:"long"}),M:n.toLocaleDateString("en-CA",{month:"short"}),m:String(n.getMonth()+1).padStart(2,"0"),n:n.getMonth()+1,d:String(n.getDate()).padStart(2,"0"),j:n.getDate(),D:n.toLocaleDateString("en-CA",{weekday:"short"}),l:n.toLocaleDateString("en-CA",{weekday:"long"}),H:String(n.getHours()).padStart(2,"0"),i:String(n.getMinutes()).padStart(2,"0"),s:String(n.getSeconds()).padStart(2,"0"),h:String(n.getHours()%12||12).padStart(2,"0"),g:n.getHours()%12||12,A:n.getHours()>=12?"PM":"AM",a:n.getHours()>=12?"pm":"am"};return t.replace(/[YyFMmnjDlHishgAa]/g,(t=>e[t]))}if(1===l)return o?"yesterday":"tomorrow";c=`about ${l} days`,c=`${l} ${1===l?"day":"days"}`}return o?`${c} ago`:`in ${c}`},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.templates=new Map,document.addEventListener("DOMContentLoaded",(()=>{window.loadTemplates()})),window.loadTemplates=function(){document.querySelectorAll("template").forEach((e=>{const t=Array.from(e.classList);if(t.length>0){const n=e.content.cloneNode(!0).firstElementChild;t.forEach((e=>{window.templates.has(e)||window.templates.set(e,n)}))}}))},window.getTemplate=function(e){return 0===window.templates.size&&window.loadTemplates(),!!window.templates.has(e)&&window.templates.get(e).cloneNode(!0)};window.jvbTemplates=new class{constructor(){this.templates=new Map,this.definitions=new Map}registerAll(e=document){e.querySelectorAll("template").forEach((e=>{e.classList.forEach((t=>{this.templates.has(t)||this.templates.set(t,e)}))}))}define(e,t={},n=null){this.definitions.set(e,{refs:t.refs||null,manyRefs:t.manyRefs||null,setup:t.setup||null,context:n})}create(e,t={}){const n=this.templates.get(e);if(!n)return console.warn(`[TemplateRegistry] Template "${e}" not found`),null;const i=n.content.cloneNode(!0).firstElementChild;if(!i)return null;const o=this.definitions.get(e),r=o?.refs?this.#e(i,o.refs):{},a=o?.manyRefs?this.#e(i,o.manyRefs,!1):{};return o?.setup?.({el:i,refs:r,manyRefs:a,data:t}),i}#e(e,t,n=!0){const i={};for(const[o,r]of Object.entries(t)){let t,a=!1;"string"==typeof r?t=r:(t=r.selector,a=!!r.required);const s=n?e.querySelector(t):e.querySelectorAll(t);a&&(n&&!s&&console.warn(`[TemplateRegistry] Required ref "${o}" not found: ${t}`),n||0!==s.length||console.warn(`[TemplateRegistry] Required manyRef "${o}" not found: ${t}`)),i[o]=n?s:Array.from(s)}return i}},document.addEventListener("DOMContentLoaded",(()=>{window.jvbTemplates.registerAll()})),window.icon=null,window.getIcon=function(e,t=""){if(void 0===e)return"";window.icon||(window.icon=document.createElement("i"),window.icon.className="icon",window.icon.ariaHidden=!0);let n=window.icon.cloneNode(!0);return t=""!==t&&["regular","bold","duotone","fill","light","thin"].includes("style")?`-${t.slice(0,2)}`:"",n.classList.add(`icon-${e}${t}`),n},window.formatNumber=function(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")},window.formatPrice=function(e,t="CAD"){return new Intl.NumberFormat("en-CA",{style:"currency",currency:t}).format(e)},window.escapeHtml=function(e){return e?("string"==typeof e||e instanceof String||(e=String(e)),e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")):""},window.removeChildren=function(e){if(0!==e.children.length)for(;e.firstChild;)e.removeChild(e.firstChild)},window.formatDateRange=function(e,t){const n=new Date(e),i=new Date(t);return n.toDateString()===i.toDateString()?n.toLocaleDateString("en-CA",{year:"numeric",month:"short",day:"numeric"}):n.getMonth()===i.getMonth()&&n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.getDate()}, ${i.getFullYear()}`:n.getFullYear()===i.getFullYear()?`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric"})}, ${i.getFullYear()}`:`${n.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})} - ${i.toLocaleDateString("en-CA",{month:"short",day:"numeric",year:"numeric"})}`},window.throttle=function(e,t=300){let n;return function(...i){n||(e.apply(this,i),n=!0,setTimeout((()=>n=!1),t))}},window.chunkIt=async function(e,t,n,i=10){const o=[];for(let t=0;t<e.length;t+=i)o.push(e.slice(t,t+i));for(const e of o){const i=document.createDocumentFragment();e.forEach((e=>{const n=t(e);n&&i.append(n)})),n(i),await new Promise((e=>requestAnimationFrame(e)))}},window.prefixInput=function(e,t,n=null,i=!1){if(!e)return void console.warn("prefixInput called with null/undefined input");const o=e.id,r=i?t:`${t}${e.name}`;let a=null;a=n?n.querySelector(`label[for="${o}"]`):e.labels&&e.labels.length>0?e.labels[0]:"LABEL"===e.previousElementSibling?.tagName?e.previousElementSibling:"LABEL"===e.nextElementSibling?.tagName?e.nextElementSibling:e.closest("[data-field]")?.querySelector(`label[for="${o}"]`),a&&(a.htmlFor=r),e.id=r},window.uppercaseFirst=function(e){return e.charAt(0).toUpperCase()+e.slice(1)},window.sanitizeHtml=function(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML},window.generateID=function(e="jvb"){return`${e}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`},window.showProgress=function(e,t,n,i="",o=""){const r=t<n;e.progress&&r&&window.fade(e.progress,!0);const a=n>0?t/n*100:0;e.fill&&(e.fill.style.width=`${a}%`),e.details&&(e.details.textContent=i),e.count&&(e.count.textContent=`${t}/${n}`),e.icon&&(e.icon.className=""===o?"icon":"icon icon-"+o),e.progress&&t===n&&window.fade(e.progress,!1)},window.formatDate=function(e){if(!e)return"";const t=new Date(e),n=new Date,i=Math.floor((n-t)/864e5);return i<1?"Today":i<2?"Yesterday":i<7?`${i} days ago`:t.toLocaleDateString()},window.getPluralContent=function(e){return"artwork"===e?"artwork":e+"s"},window.showToast=function(e,t="success",n={}){window.jvbNotifications.showToast(e,t,n)},window.dateFormatter=new Intl.DateTimeFormat("en-CA",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"}),window.formatDate=function(e){return e instanceof Date&&!isNaN(e)||(e=new Date(e)),window.dateFormatter.format(e)},window.typeText=function(e,t,n=50){return new Promise((i=>{e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval);let o=0;e.textContent="",e._typeInterval=setInterval((()=>{o<t.length?(e.textContent+=t.charAt(o),o++):(clearInterval(e._typeInterval),delete e._typeInterval,i())}),n)}))},window.eraseText=function(e,t=10){return new Promise((n=>{e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval);let i=e.textContent,o=i.length;e._eraseInterval=setInterval((()=>{o>0?(o--,e.textContent=i.substring(0,o)):(clearInterval(e._eraseInterval),delete e._eraseInterval,n())}),t)}))},window.typeLoop=function(e,t,n=50,i=10,o=1e3,r=250){const a=e.id||e.dataset.typeKey||`type-${Date.now()}`;e.dataset.typeKey||(e.dataset.typeKey=a),e._stopTyping&&e._stopTyping();let s=!0;const l=function(){s=!1,e._typeInterval&&(clearInterval(e._typeInterval),delete e._typeInterval),e._eraseInterval&&(clearInterval(e._eraseInterval),delete e._eraseInterval)};return e._stopTyping=l,async function(){for(;s&&(await window.typeText(e,t,n),s)&&(await new Promise((e=>setTimeout(e,o))),s)&&(await window.eraseText(e,i),s);)await new Promise((e=>setTimeout(e,r)))}(),l},window.toCamelCase=function(e){return e.replace(/-([a-z])/g,(function(e){return e[1].toUpperCase()}))},window.targetCheck=function(e,t){return Array.isArray(t)&&(t=t.join(",")),"string"==typeof t&&(e.target.closest(t)??!1)},window.getDifferences={VALUE_CREATED:"created",VALUE_UPDATED:"updated",VALUE_DELETED:"deleted",VALUE_UNCHANGED:"unchanged",map:function(e,t){if(this.isFunction(e)||this.isFunction(t))throw"Invalid argument. Function given, object expected.";if(this.isFile(e)||this.isFile(t)){const n=this.compareFiles(e,t);return n===this.VALUE_UNCHANGED?null:{type:n,data:void 0===e?t:e}}if(this.isValue(e)||this.isValue(t)){const n=this.compareValues(e,t);if(n===this.VALUE_UNCHANGED)return null;let i;switch(n){case this.VALUE_CREATED:i=t;break;case this.VALUE_DELETED:i=this.getEmptyValue(e);break;case this.VALUE_UPDATED:default:i=t}return{type:n,data:i}}let n={},i=!1;for(let o in e)if(!this.isFunction(e[o])){let r;t&&void 0!==t[o]&&(r=t[o]);const a=this.map(e[o],r);null!==a&&(a.hasOwnProperty("type")&&a.hasOwnProperty("data")?n[o]=a.data:n[o]=a,i=!0)}if(t)for(let o in t)if(!this.isFunction(t[o])&&(void 0===e||void 0===e[o])){const e=this.map(void 0,t[o]);null!==e&&(e.hasOwnProperty("type")&&e.hasOwnProperty("data")?n[o]=e.data:n[o]=e,i=!0)}return i?n:null},getEmptyValue:function(e){return this.isArray(e)?[]:this.isObject(e)?{}:"number"==typeof e?0:"boolean"!=typeof e&&""},compareValues:function(e,t){return e===t||this.isDate(e)&&this.isDate(t)&&e.getTime()===t.getTime()?this.VALUE_UNCHANGED:void 0===e?this.VALUE_CREATED:void 0===t?this.VALUE_DELETED:this.VALUE_UPDATED},isFunction:function(e){return"[object Function]"===Object.prototype.toString.call(e)},isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},isDate:function(e){return"[object Date]"===Object.prototype.toString.call(e)},isObject:function(e){return"[object Object]"===Object.prototype.toString.call(e)},isFile:function(e){return e instanceof File},isValue:function(e){return!this.isObject(e)&&!this.isArray(e)},compareFiles:function(e,t){return!this.isFile(e)&&this.isFile(t)?this.VALUE_CREATED:this.isFile(e)&&!this.isFile(t)?this.VALUE_DELETED:this.isFile(e)&&this.isFile(t)?e.name===t.name&&e.size===t.size&&e.type===t.type&&e.lastModified===t.lastModified?this.VALUE_UNCHANGED:this.VALUE_UPDATED:this.VALUE_UNCHANGED},merge:function(e,t){if(null==e)return t;if(null==t)return e;if(this.isFunction(e)||this.isFunction(t))return t;if(this.isFile(e)||this.isFile(t))return t;if(this.isValue(e)||this.isValue(t)||this.isArray(e)||this.isArray(t))return t;if(this.isObject(e)&&this.isObject(t)){let n={};for(let t in e)this.isFunction(e[t])||(n[t]=e[t]);for(let i in t)this.isFunction(t[i])||(void 0!==e[i]?n[i]=this.merge(e[i],t[i]):n[i]=t[i]);return n}return t}},window.deepMerge=function(e,t){return window.getDifferences.merge(e,t)},window.isInt=function(e){return!isNaN(parseInt(e))&&isFinite(e)},window.isNumeric=function(e){return!isNaN(parseFloat(e))&&isFinite(e)},window.uiFromSelectors=function(e,t=null,n=!1){let i={};for(let[o,r]of Object.entries(e))i[o]="object"==typeof r?window.uiFromSelectors(r,t):t?n?t.querySelectorAll(r):t.querySelector(r):n?document.querySelectorAll(r):document.querySelector(r);return i};window.debouncer=new class{constructor(){this.timeouts=new Map,window.addEventListener("beforeunload",(()=>this.cleanup()))}schedule(e,t,n=1e3){this.cancel(e),this.timeouts.set(e,setTimeout((()=>{t(),this.timeouts.delete(e)}),n))}cancel(e){this.timeouts.has(e)&&(clearTimeout(this.timeouts.get(e)),this.timeouts.delete(e))}cleanup(){for(let e of this.timeouts.values())clearTimeout(e);this.timeouts.clear()}};document.body;const e=document.documentElement,t=document.querySelector(".scroll-progress .bar");let n=window.scrollY||e.scrollTop||0,i=-1,o=!1,r=0;function a(){r=Math.max(0,e.scrollHeight-window.innerHeight)}function s(e){if(!t)return;const n=r>0?e/r:0,i=Math.max(0,Math.min(1,n));t.style.transform=`scaleX(${i})`}function l(){const t=window.scrollY||e.scrollTop||0;t>n?i=1:t<n&&(i=-1),n=t,document.body.classList.toggle("scroll-up",i<0&&t>0),s(t),o=!1}window.addEventListener("scroll",(()=>{o||(o=!0,requestAnimationFrame(l))}),{passive:!0}),window.addEventListener("resize",(()=>{window.debouncer.schedule("recalc-max-scroll",(()=>{a(),s(window.scrollY||e.scrollTop||0)}),20)})),a(),s(n)})();
\ No newline at end of file
diff --git a/build/drawer-menu/render.php b/build/drawer-menu/render.php
index d0826a3..05da41a 100644
--- a/build/drawer-menu/render.php
+++ b/build/drawer-menu/render.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\ui\Navigation;
 
 $menu_id = $attributes['menuId'] ?? '';
@@ -13,8 +13,7 @@
 	return '<p>Please configure the drawer menu in block settings.</p>';
 }
 
-$cache = CacheManager::for('drawer');
-$cache->clear();
+$cache = Cache::for('drawer');
 
 if (!is_front_page()) {
 	$menu_items[] = [
diff --git a/build/feed/style-index-rtl.css b/build/feed/style-index-rtl.css
index c7ef499..20c3e06 100644
--- a/build/feed/style-index-rtl.css
+++ b/build/feed/style-index-rtl.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{right:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{right:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));right:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed.item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-left:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(1,1fr)}@media(min-width:768px){.item-grid:has([data-timeline]){grid-template-columns:repeat(2,1fr)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{right:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:none;height:10px;right:5px;transform:translateY(-70%) rotate(-45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;right:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:block;height:.66rem;right:5px;top:50%;transform:translateY(-70%) rotate(-45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-right:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-right:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
+.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{right:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{right:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));right:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed.item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-left:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.items-wrap [type=checkbox],.items-wrap [type=radio]{right:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:none;height:10px;right:5px;transform:translateY(-70%) rotate(-45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;right:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 0 2px 2px;display:block;height:.66rem;right:5px;top:50%;transform:translateY(-70%) rotate(-45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-right:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-right:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
diff --git a/build/feed/style-index.css b/build/feed/style-index.css
index d520da0..6c76b3e 100644
--- a/build/feed/style-index.css
+++ b/build/feed/style-index.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{left:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{left:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));left:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed.item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-right:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(1,1fr)}@media(min-width:768px){.item-grid:has([data-timeline]){grid-template-columns:repeat(2,1fr)}}.items-wrap [type=checkbox],.items-wrap [type=radio]{left:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:none;height:10px;left:5px;transform:translateY(-70%) rotate(45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;left:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:block;height:.66rem;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-left:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-left:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
+.feed-block{grid-column:full}.feed-block .filters{margin:0 auto;max-width:var(--wide);padding:1rem 0}.feed-block .filters .remove-term.remove-term{height:-moz-max-content;height:max-content;width:-moz-max-content;width:max-content}.feed-block .filter-group{padding:2rem 0;position:relative}.feed-block .filter-group .label{left:0;position:absolute}.feed-block .filter-group>.label{top:0}.feed-block .filter-group [type=radio]{left:var(--offScreen);position:absolute}.feed-block .filter-group button,.feed-block .filter-group label{height:-moz-max-content;height:max-content;padding:.5rem;position:relative}.feed-block .filter-group button:hover,.feed-block .filter-group label:hover{color:var(--action-contrast)}.feed-block .filter-group :checked+label .label,.feed-block .filter-group button:hover .label,.feed-block .filter-group label:hover .label{opacity:1;visibility:visible}.feed-block .filter-group button .label,.feed-block .filter-group label .label,.feed-block .filter-group:has(label:hover) :checked+label .label{--height:max-content;bottom:-2rem;font-weight:var(--fw-b);opacity:0;visibility:hidden;white-space:nowrap;width:-moz-max-content;width:max-content}.feed-block h3{font-size:var(--medium);margin:0 0 .25rem}.placeholder{align-items:center;aspect-ratio:1;background:var(--base);border:1rem solid var(--base-50);border-radius:1rem;display:flex;justify-content:center}.placeholder i.icon{--w:50%;animation:dance 2.5s ease-in-out infinite;color:var(--base-200)}.item-grid{max-width:none;padding:0 var(--chip)}.feed.item{background:var(--base-50);border-radius:.5rem;box-shadow:0 2px 4px rgba(0,0,0,.1);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0;position:relative}.feed.item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed.item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base-rgb),var(--op-2));left:0;position:absolute;top:-3rem;width:100%}.feed.item details summary:hover{background-color:rgba(var(--action-rgb),var(--op-45))}.feed.item details[open]{padding:.25rem .5rem}.feed.item details[open] summary .icon{opacity:0}.feed.item img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.feed.item img:hover{opacity:.8}.feed.item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed.item[data-timeline] .images span{background-color:var(--action-0);color:var(--action-contrast);padding:.25rem .5rem;position:absolute;width:50%}.feed.item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed.item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed.item[data-timeline] .images>a{display:flex;flex-wrap:nowrap;height:100%;position:relative;width:100%}.feed.item[data-timeline] img{height:100%;-o-object-fit:cover;object-fit:cover;width:50%}.feed.item[data-timeline] img:first-of-type{border-right:1px solid var(--action-0)}.feed.item a:after,.feed.item a:before{display:none}.feed.item label{font-weight:400;text-transform:none}.feed.item label .icon{--w:1.5em}.item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.items-wrap [type=checkbox],.items-wrap [type=radio]{left:-200vw;opacity:0;position:absolute}.items-wrap [type=checkbox]+label,.items-wrap [type=radio]+label{cursor:pointer;position:relative}.items-wrap [type=checkbox]+label:hover,.items-wrap [type=radio]+label:hover{color:var(--action-0)}.items-wrap [type=checkbox]+label:after,.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:after,.items-wrap [type=radio]+label:before{content:"";position:absolute;top:50%}.items-wrap [type=checkbox]+label:after,.items-wrap [type=radio]+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:none;height:10px;left:5px;transform:translateY(-70%) rotate(45deg);width:5px}.items-wrap [type=checkbox]+label:before,.items-wrap [type=radio]+label:before{background-color:var(--base);border:2px solid var(--contrast-200);border-radius:var(--radius);height:1rem;left:0;transform:translateY(-50%);width:1rem}.items-wrap [type=checkbox]:hover+label:before,.items-wrap [type=radio]:hover+label:before{border-color:var(--action-200)}.items-wrap [type=checkbox]:checked+label:before,.items-wrap [type=radio]:checked+label:before{background-color:var(--action-0);border-color:var(--action-100)}.items-wrap [type=radio]:checked+label:before{border-radius:50%}.items-wrap [type=checkbox]:checked+label:after{border:solid var(--light-0);border-width:0 2px 2px 0;display:block;height:.66rem;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem}.items-wrap :disabled+label{cursor:not-allowed}.items-wrap :disabled+label,.items-wrap :disabled+label:hover{background-color:var(--base-50);border-color:var(--base-200);color:var(--base-200)}.items-wrap :disabled+label:before{border-color:var(--base-200)}#jvb-selector .items-wrap [type=checkbox]+label,#jvb-selector .items-wrap [type=radio]+label{flex:1;padding-left:2rem!important;transform-origin:top center;will-change:transform}.feed-block+footer{background-color:var(--base-50);display:flex;grid-column:full;justify-content:flex-end;margin:0;padding:0!important;z-index:0}.feed-block+footer button{margin-left:auto;padding:.35rem .25rem;width:-moz-max-content;width:max-content;--w:1.3em!important;flex-wrap:nowrap;font-size:var(--txt-x-small);justify-content:flex-start;min-height:0;transition:var(--trans-size)}.feed-block+footer button span{display:none;white-space:nowrap}.feed-block+footer button:focus span,.feed-block+footer button:hover span{display:block}
diff --git a/build/feed/view.asset.php b/build/feed/view.asset.php
index aad0cb3..90ed37f 100644
--- a/build/feed/view.asset.php
+++ b/build/feed/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '22ba781dbd65270a003b');
+<?php return array('dependencies' => array(), 'version' => '1589cfb61e8639162b4c');
diff --git a/build/feed/view.js b/build/feed/view.js
index d261b31..4245a34 100644
--- a/build/feed/view.js
+++ b/build/feed/view.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".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"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",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),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.content=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.content=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.content=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.content=!1),this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch())}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){const e=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:216e5,showLoading:!0,required:"content"});this.store=e.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(){let e=this.store.getFiltered();this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.fields.number-1} Tx`),s&&(s.textContent=e.fields.number),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("section.feed-block"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.cache=new window.jvbCache("feed"),this.templates=window.jvbTemplates,this.config={source:"",context:"",highlight:null,gallery:!1,view:this.cache.get("feedView")||"grid",...this.container.dataset},this.init())}init(){this.initElements(),this.defineTemplates(),this.initListeners(),this.initFilters(),"requestIdleCallback"in window?requestIdleCallback((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),{timeout:2e3}):setTimeout((()=>{this.initStore(),this.initTaxonomies(),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}),100)}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".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"]'},grid:".item-grid",selected:".selected-items",buttons:{loadMore:"button.load-more",remove:".remove-term",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),this.ui.content=this.ui.filters.container.querySelectorAll('[name="content"]'),0===this.ui.content.length&&(this.ui.content=!1),this.ui.taxonomies=this.ui.filters.container.querySelectorAll("[data-taxonomy]"),0===this.ui.taxonomies.length&&(this.ui.taxonomies=!1),this.ui.orderbyWrap=this.ui.filters.container.querySelector("[data-for-order]"),0===this.ui.orderbyWrap.length&&(this.ui.orderbyWrap=!1),this.ui.order=this.ui.filters.container.querySelectorAll('[data-filter="order"]'),0===this.ui.order.length&&(this.ui.order=!1),this.ui.orderby=this.ui.filters.container.querySelectorAll('[data-filter="orderby"]'),0===this.ui.orderby.length&&(this.ui.orderby=!1),this.orderbyFilters=this.ui.orderby?Array.from(this.ui.orderby).map((e=>e.value)):[],this.contentTypes=this.ui.content?Array.from(this.ui.content).map((e=>e.value)):[this.container.dataset.content],this.taxonomies=this.ui.taxonomies?.length>0?Array.from(this.ui.taxonomies).map((e=>e.dataset.taxonomy)):[]}initListeners(){this.popStateHandler=this.handlePopState.bind(this),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),window.addEventListener("popstate",this.popStateHandler),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}initFilters(){this.allowedFilters=["content","order","orderby","favourites","match"];let e={content:this.contentTypes[0],orderby:"date",order:"desc",page:1};this.config.context&&(e.context=this.config.context),this.config.source&&(e.source=this.config.source),this.filters=e,this.defaults={...e}}updateFilterUI(){if(this.ui.filters.container&&([this.ui.content,this.ui.orderby,this.ui.order].forEach((e=>{if(e)for(let t of e){let[e,i]=[t.dataset.filter,t.value];if(!Object.hasOwn(this.store.filters,e))break;let s=this.store.filters[e]===i;if(s){t.checked=s;break}}})),Object.hasOwn(this.store.filters,"taxonomy")))for(let[e,t]of Object.entries(this.store.filters.taxonomy))t.forEach((e=>{e=parseInt(e),this.selector.store.get(e)&&this.createTermElement(e)}))}handlePopState(e){e.state?.filters&&this.processURLFilters()&&(this.store.setFilters(this.filters),this.a11y.announce("Feed filters updated from browser history"))}handleClick(e){window.targetCheck(e,this.selectors.buttons.loadMore)?this.nextPage():window.targetCheck(e,this.selectors.buttons.clearFilters)&&this.clearFilters();let t=window.targetCheck(e,this.selectors.buttons.remove);t&&this.removeSelectedTerm(t),window.targetCheck(e,this.selectors.buttons.refresh)&&(this.store.clearCache(),this.store.fetch());let i=window.targetCheck(e,'[data-filter="orderby"]');i&&"random"===i.value&&i.checked&&this.renderItems()}nextPage(){const e=(this.store.filters.page||1)+1,t=this.store.lastResponse?.pages||e;this.store.setFilters({page:Math.min(e,t)})}handleChange(e){const t=e.target;if(Object.hasOwn(t.dataset,"filter")){if(this.allowedFilters.includes(t.dataset.filter)){let e={};e[t.dataset.filter]=t.value,this.resetFilters(e)}switch(t.dataset.filter){case"content":this.updateContentFor(t.value);break;case"orderby":this.updateOrderOptions(t.value)}}}clearFilters(){this.taxFilters={},window.removeChildren(this.ui.selected),this.taxonomies.forEach((e=>{let t=this.getFieldId(e);this.selector.selectedTerms.get(t)?.clear()})),this.store.setFilters({...this.defaults,taxonomy:null}),this.updateURL(),this.saveToCacheFilters()}resetFilters(e){e={...this.store.filters,page:1,...e},this.store.setFilters(e),this.updateURL(),this.saveToCacheFilters()}getFieldId(e){var t;return this.selector.getFieldId(null!==(t=Array.from(this.ui.taxonomies).filter((t=>t.dataset.taxonomy===e))[0])&&void 0!==t?t:null)}removeSelectedTerm(e){const t=parseInt(e.dataset.id),i=e.dataset.taxonomy;Object.hasOwn(this.taxFilters,i)&&(this.taxFilters[i]=this.taxFilters[i].filter((e=>e!==t)),0===this.taxFilters[i].length&&delete this.taxFilters[i]),e.remove();const s=this.getFieldId(i);s&&(this.selector.activeField=s,this.selector.removeSelected(t,s)),this.resetFilters({taxonomy:Object.keys(this.taxFilters).length>0?this.taxFilters:null})}updateContentFor(e){[this.ui.taxonomies,this.ui.orderby].forEach((t=>{t&&t.forEach((t=>{var i;const s=null!==(i=t.dataset.for?.split(","))&&void 0!==i?i:[];t.hidden=s.length>0&&!s.includes(e),t.hidden&&t.checked&&(t.checked=!1)}))}))}updateOrderOptions(e){if(this.ui.orderbyWrap){var t;let i=null!==(t=this.ui.orderbyWrap.dataset.forOrder.split(","))&&void 0!==t?t:[];this.ui.orderbyWrap.hidden=!i.includes(e)}}updateFilterControls(){const e=0===Object.keys(this.taxFilters).length;this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=e),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e)}async initTaxonomies(){this.taxFilters={},this.selector=window.jvbSelector,this.selector.subscribe(((e,t)=>{"selected-terms"===e&&this.handleTaxonomyChange(t)}))}handleTaxonomyChange(e){const{terms:t,taxonomy:i}=e;0!==t.size&&(this.taxFilters[i]=Array.from(t),this.resetFilters({taxonomy:this.taxFilters}),t.forEach((e=>{this.createTermElement(e)})),this.updateFilterControls())}getTaxonomyIcon(e){let t=Array.from(this.ui.taxonomies).find((t=>t.dataset.taxonomy===e));return t?.dataset.icon.trim()||"tag"}createTermElement(e){const t=this.selector.store.get(e);t&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||(t.icon=this.getTaxonomyIcon(t.taxonomy),this.ui.selected.append(this.templates.create("feedTerm",t))))}processCachedFilters(){Object.keys(this.filters).forEach((e=>{let t=this.cache.get(`${this.config.source}_${this.config.context}_${e}`);t&&t!==this.filters[e]&&(this.filters[e]=t)}))}processURLFilters(){if(!this.isFirstPage())return!1;const e=new URLSearchParams(window.location.search);if(!e.toString())return!1;let t=!1;this.allowedFilters.forEach((i=>{let s=e.get(`f_${i}`);s&&(t=!0,this.filters[i]=s)}));let i=!1;return e.forEach(((e,s)=>{if(s.startsWith("f_tax_")){i=!0,t=!0;const r=s.replace("f_tax_","");this.taxFilters[r]=e.split(",").map(Number)}})),t&&(i&&(this.filters.taxonomy=this.taxFilters),this.resetFilters(this.filters)),!0}updateURL(){const e=new URLSearchParams;this.allowedFilters.forEach((t=>{Object.hasOwn(this.store.filters,t)&&this.store.filters[t]!==this.defaults[t]&&e.set(`f_${t}`,this.store.filters[t])}));for(let[t,i]of Object.entries(this.taxFilters))i.length>0&&e.set(`f_tax_${t}`,i.join(","));const t=`${window.location.pathname}${e.toString()?"?"+e.toString():""}`;t!==window.location.pathname+window.location.search&&window.history.pushState({filters:this.store.filters},"",t)}saveToCacheFilters(){Object.keys(this.store.filters).forEach((e=>{const t=`${this.config.source}_${this.config.context}_${e}`;this.store.filters[e]!==this.defaults[e]?this.cache.set(t,this.store.filters[e]):this.cache.remove(t)}));const e=`${this.config.source}_${this.config.context}_taxonomy`;Object.keys(this.taxFilters).length>0?this.cache.set(e,this.taxFilters):this.cache.remove(e)}initGallery(){this.gallery=!!this.config.gallery&&window.jvbGallery,this.gallery&&this.gallery.subscribe(((e,t)=>{"load-more"===e&&this.store.lastResponse?.has_more&&this.nextPage()}))}initStore(){let e=this.orderbyFilters.filter((e=>!["date","modified","title","random"].includes(e))),t=[];e.forEach((e=>{t.push({name:e,keyPath:e})}));const i=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:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"},...t],filters:this.filters,TTL:216e5,showLoading:!0,required:"content"});this.store=i.feed,this.store.subscribe(((e,t)=>{var i;"data-loaded"===e&&(this.renderItems(t.items),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=null===(i=!this.store.lastResponse?.has_more)||void 0===i||i))}))}isFirstPage(){return 1===this.store.filters.page}renderItems(e=null){e=null!=e?e:this.store.getFiltered(),this.isFirstPage()&&window.removeChildren(this.ui.grid),0===e.length?(this.showEmptyState(),this.a11y.announceItems(0,this.isFirstPage())):window.chunkIt(e,(e=>this.createItemElement(e)),(t=>{var i;this.removePlaceholders(),this.ui.grid.append(t),this.config.gallery&&this.gallery.buildGalleryItems(".item img"),this.a11y.makeNavigable(this.ui.grid.querySelectorAll(".item:not([data-keyboard-nav])")),this.a11y.announceItems(e.length,!this.isFirstPage(),null!==(i=this.store.lastResponse?.has_more)&&void 0!==i&&i)}),5).then((()=>{})),this.updateFilterControls()}showEmptyState(){window.removeChildren(this.ui.grid),this.ui.grid.append(this.templates.create("emptyState"))}createItemElement(e){if("object"==typeof e||(e=this.store.get(e)))return this.templates.create(`feedItem${window.uppercaseFirst(e.content)}`,e)}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>e))}isImageField(e,t){return!(!Object.hasOwn(e,"images")||0===Object.keys(e.images).length)&&this.splitIDs(t).some((t=>Object.keys(e.images).map((e=>parseInt(e))).includes(parseInt(t))))}formatImageFields(e,t,i){let s=this.splitIDs(t);if(0!==s.length)if(s.length>1){let t=e.querySelector("img");if(!t)return;s.forEach((s=>{let r=t.cloneNode(!0);this.formatImageField(r,s,i),e.append(r)})),t.remove()}else{if("IMG"!==e.tagName&&!(e=e.querySelector("img")))return;this.formatImageField(e,s[0],i)}}formatImageField(e,t,i){var s;let r=null!==(s=i.images[t])&&void 0!==s&&s;r&&([e.src,e.srcset,e.alt]=[r.tiny,`${r.tiny} 50w, ${r.small} 300w, ${r.medium} 1024w`,r["image-alt-text"]])}isTaxonomyField(e,t){return!(!Object.hasOwn(e,"taxonomies")||0===Object.keys(e.taxonomies).length)&&Object.keys(e.taxonomies).includes(t)}formatTaxonomyField(e,t,i,s){if("UL"!==e.tagName||!e.querySelector("li"))return;let r=this.splitIDs(s);0===r.length&&e.remove();let o=e.querySelector("li");for(let s of r){var a;let r=null!==(a=t.taxonomies[i][s])&&void 0!==a&&a;if(!r)continue;let n=o.cloneNode(!0),l=n.querySelector("a");l&&([l.href,l.title,l.textContent]=[r.url,`See more ${r.title}`,r.title],e.append(n))}o.remove()}isTimeField(e){return"TIME"===e.tagName||null!==e.querySelector("time")}formatTimeField(e,t){("TIME"===e.tagName||(e=e.querySelector("time")))&&(e.setAttribute("datetime",t),e.textContent=window.formatTimeAgo(t,"F Y"))}formatField(e,t){e.textContent=t}addTimelineElements(e,t){let[i,s,r,o]=[t.querySelector("span.after-text"),t.querySelector('[data-field="number"] b'),t.querySelector('[data-field="started"] time'),t.querySelector('[data-field="updated"] time')];i&&(i.textContent=`After ${e.number} Tx`),s&&(s.textContent=e.fields.number),r&&this.formatTimeField(r,e.fields.timeline[0].post_date),o&&this.formatTimeField(o,e.fields.timeline[e.fields.timeline.length-1].post_date)}removePlaceholders(){const e=this.ui.grid.querySelectorAll(".placeholder");e.length>0&&e.forEach((e=>e.remove()))}defineTemplates(){const e=this.templates,t=this;e.define("feedTerm",{refs:{icon:".icon",span:"span"},setup({el:e,refs:t,manyRefs:i,data:s}){e.dataset.id=s.id,e.dataset.taxonomy=s.taxonomy,t.icon&&(t.icon.className=`icon icon=${s.icon}`),t.span&&(t.span.textContent=s.name)}}),e.define("emptyState"),this.contentTypes.forEach((i=>{e.define(`feedItem${window.uppercaseFirst(i)}`,{refs:{link:"a"},manyRefs:{fields:"[data-field]"},setup({el:e,refs:i,manyRefs:s,data:r}){const o=Object.hasOwn(e.dataset,"timeline");if(s.fields){for(let e of s.fields){if(o&&["timeline","number"].includes(e.dataset.field))continue;const i=!!Object.hasOwn(r.fields,e.dataset.field)&&r.fields[e.dataset.field];i?t.isImageField(r,i)?t.formatImageField(e,i,r):t.isTaxonomyField(r,e.dataset.field)?t.formatTaxonomyField(e,r,e.dataset.field,i):t.isTimeField(e)?t.formatTimeField(e,i):t.formatField(e,i):e.remove()}var a;i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${null!==(a=r.fields.post_title)&&void 0!==a?a:"Item"}`),o&&t.addTimelineElements(r,e)}}})}))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.feedBlock=new e)}))}))})();
\ No newline at end of file
diff --git a/build/list/render.php b/build/list/render.php
index f8ef568..489c379 100644
--- a/build/list/render.php
+++ b/build/list/render.php
@@ -60,7 +60,7 @@
                             if ($terms && !is_wp_error($terms)) {
                                 $term = $terms[0];
                                 $extra[] = [
-                                    'name'  => $term->name,
+                                    'name'  => html_entity_decode($term->name),
                                     'url'   => get_term_link($term->term_id, $item),
                                     'id'    => $term->term_id,
                                     'type'  => $item,
@@ -90,7 +90,7 @@
                     $extra = false;
                     $list = jvbAlphabetizeMe(
                         $list,
-                        $term->name,
+						html_entity_decode($term->name),
                         get_term_link($term->term_id, $selected_type['slug']),
                         $term->term_id,
                         $extra
diff --git a/build/summary/render.php b/build/summary/render.php
index 4c22c7a..ca5cde1 100644
--- a/build/summary/render.php
+++ b/build/summary/render.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 
 if (!defined('ABSPATH')) {
     exit; // Exit if accessed directly
@@ -31,10 +31,9 @@
 function jvbRenderArtistSummary():string
 {
     $current = get_queried_object();
-    $cache = CacheManager::for('artists', WEEK_IN_SECONDS);
-    $key = 'artist-bio-'.$current->ID;
+    $cache = Cache::for('artistSummary', WEEK_IN_SECONDS);
+    $key = $current->ID;
     $cached = $cache->get($key);
-    $cached = false;
     if ($cached !== false) {
         return $cached;
     }
@@ -97,8 +96,8 @@
                             $link = get_term_link((int)$style, BASE.'style');
                             ?>
                             <li>
-                                <a href="<?=$link?>" title="Learn more about <?=$term->name?>">
-                                    <?=strtolower($term->name)?>
+                                <a href="<?=$link?>" title="Learn more about <?=html_entity_decode($term->name)?>">
+                                    <?=strtolower(html_entity_decode($term->name))?>
                                 </a>
                             </li>
                             <?php
@@ -158,8 +157,8 @@
 {
     $current = get_queried_object();
 
-    $cache = CacheManager::for('shops', WEEK_IN_SECONDS);
-    $key = 'shop-bio-'.$current->term_id;
+    $cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
+    $key = $current->term_id;
     $cached = $cache->get($key);
     $cached = false;
     if ($cached !== false) {
@@ -167,8 +166,6 @@
     }
 
     ob_start();
-    $handler = JVB()->getContent('shop');
-
 
     $meta = new JVBase\meta\MetaManager($current->term_id, 'term');
     $rating = $meta->getValue('average_rating');
@@ -281,7 +278,7 @@
 
     <?php
     $finished = ob_get_clean();
-//    $cache->set($key, $finished);
+    $cache->set($key, $finished);
     return $finished;
 }
 
@@ -289,10 +286,9 @@
 function jvbRenderTermSummary()
 {
     $current = get_queried_object();
-    $cache = CacheManager::for(jvbNoBase($current->taxonomy), WEEK_IN_SECONDS);
+    $cache = Cache::for('term_summary', WEEK_IN_SECONDS)->connect('taxonomy');
     $key = $current->ID;
     $cached = $cache->get($key);
-    $cached = false;
     if ($cached !== false) {
         return $cached;
     }
diff --git a/checks.php b/checks.php
index 3c6ee74..86c7526 100644
--- a/checks.php
+++ b/checks.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\utility\Features;
 
 if (!defined('ABSPATH')) {
@@ -261,27 +261,26 @@
 
 function jvbTermHasPosts(int $termID, string $taxonomy):bool
 {
-	$cache = CacheManager::for('terms', 30*60)->connectTo('taxonomy');
-	$key = $termID.$taxonomy;
-	$cached = $cache->get($key);
-	if ($cached) {
-		return ($cached === 'true');
-	}
-	$taxonomy = jvbCheckBase($taxonomy);
-	$tax = get_taxonomy($taxonomy);
-	$query = new WP_Query([
-		'post_type'	=> $tax->object_type,
-		'posts_per_page'	=> 1,
-		'fields'	=> 'id',
-		'tax_query'	=> [
-			[
-				'taxonomy'	=> $taxonomy,
-				'terms'	=> $termID
-			]
-		]
-	]);
-	$result = ($query->have_posts()) ? 'true': 'false';
-	wp_reset_postdata();
-	$cache->set($key, $result);
-	return $result === 'true';
+	$cache = Cache::for('termUtility', 30*60)->connect('taxonomy');
+	return $cache->remember(
+		$termID,
+		function() use($taxonomy, $termID) {
+			$taxonomy = jvbCheckBase($taxonomy);
+			$tax = get_taxonomy($taxonomy);
+			$query = new WP_Query([
+				'post_type'	=> $tax->object_type,
+				'posts_per_page' => 1,
+				'fields' => 'ids',
+				'tax_query'	=> [
+					[
+						'taxonomy' => $taxonomy,
+						'terms'	=> $termID
+					]
+				]
+			]);
+			$result = ($query->have_posts()) ? 'true' : 'false';
+			wp_reset_postdata();
+			return $result;
+		}
+	);
 }
diff --git a/inc/EmbedGenerator.php b/inc/EmbedGenerator.php
index faac311..63c1f11 100644
--- a/inc/EmbedGenerator.php
+++ b/inc/EmbedGenerator.php
@@ -176,7 +176,7 @@
             foreach ($style_ids as $style_id) {
                 $term = get_term((int) $style_id, BASE . 'style');
                 if ($term && !is_wp_error($term)) {
-                    $styles[] = $term->name;
+                    $styles[] = html_entity_decode($term->name);
                 }
             }
         }
diff --git a/inc/blocks/CustomBlocks.php b/inc/blocks/CustomBlocks.php
index 00613e1..1822fb5 100644
--- a/inc/blocks/CustomBlocks.php
+++ b/inc/blocks/CustomBlocks.php
@@ -3,7 +3,7 @@
 
 use DateTime;
 use DOMDocument;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use WP_Block;
 use WP_Query;
 
@@ -13,10 +13,11 @@
 
 class CustomBlocks
 {
-    protected CacheManager $cache;
+    protected Cache $cache;
     public function __construct()
     {
-        $this->cache = CacheManager::for('blocks', WEEK_IN_SECONDS);
+        $this->cache = Cache::for('blocks', WEEK_IN_SECONDS);
+		$this->cache->connect('post')->connect('taxonomy');
 		add_filter('render_block', [$this, 'render'], 999, 3);
 
         add_action('init', [$this, 'registerBlockStyles']);
@@ -72,16 +73,16 @@
 		if (function_exists($function)) {
 			return $function($block, $content);
 //			return $this->cache->remember(
-//				$block,
+//				get_the_ID(),
 //				function () use ($function, $block, $content) {
 //					return $function($block, $content);
 //				}
 //			);
 		} else if (method_exists($this, $method)) {
 			return $this->$method($block, $content);
-
+//
 //			return $this->cache->remember(
-//				$block,
+//				get_the_ID(),
 //				function () use ($method, $block, $content) {
 //					return $this->$method($block, $content);
 //				}
@@ -654,7 +655,7 @@
 					$out .= '<li>'.$block['attrs']['prefix'].'</li>';
 				}
 				foreach($terms as $term) {
-					$out .= '<li><a href="'.get_term_link($term).'" rel="tag">'.$term->name.'</a></li>';
+					$out .= '<li><a href="'.get_term_link($term).'" rel="tag">'.html_entity_decode($term->name).'</a></li>';
 				}
 			if (array_key_exists('suffix', $block['attrs'])) {
 				$out .= '<li>'.$block['attrs']['suffix'].'</li>';
diff --git a/inc/blocks/FAQBlock.php b/inc/blocks/FAQBlock.php
index b27af52..6697669 100644
--- a/inc/blocks/FAQBlock.php
+++ b/inc/blocks/FAQBlock.php
@@ -1,17 +1,17 @@
 <?php
 namespace JVBase\blocks;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\forms\TaxonomySelector;
 use JVBase\meta\MetaManager;
 use WP_Block;
 use WP_Query;
 
 class FAQBlock {
-	protected CacheManager $cache;
+	protected Cache $cache;
 	public function __construct()
 	{
-		$this->cache = CacheManager::for('faq_block', WEEK_IN_SECONDS);
+		$this->cache = Cache::for('faq_block', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
 		add_action('init', [ $this, 'registerBlock' ]);
 		add_action('enqueue_block_editor_assets', [$this, 'localizeData']);
 	}
@@ -89,7 +89,7 @@
 					foreach ($sections as $term) {
 						$sections_data[] = [
 							'id' => $term->term_id,
-							'name' => $term->name,
+							'name' => html_entity_decode($term->name),
 							'slug' => $term->slug,
 						];
 					}
@@ -256,7 +256,7 @@
 				$term = get_term($term_id, $section_taxonomy);
 				if ($term && !is_wp_error($term)) {
 					$url = (!$is_tax_archive) ? "#{$term->slug}" : get_term_link($term);
-					$nav .= '<li><a href="'.$url.'">'.$term->name.'</a></li>';
+					$nav .= '<li><a href="'.$url.'">'.html_entity_decode($term->name).'</a></li>';
 				}
 			}
 			$seeAll = ($is_tax_archive) ? '<p><a href="'.get_post_type_archive_link(BASE.'faq').'">'.__('See All FAQs', 'jvb').'</a></p>' : '';
diff --git a/inc/blocks/FeedBlock.php b/inc/blocks/FeedBlock.php
index 45a0959..1a23d95 100644
--- a/inc/blocks/FeedBlock.php
+++ b/inc/blocks/FeedBlock.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\blocks;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\utility\Features;
 use JVBase\utility\Checker;
 use JVBase\forms\TaxonomySelector;
@@ -13,41 +13,22 @@
 
 class FeedBlock
 {
-	protected CacheManager $cache;
+	protected Cache $cache;
 	protected array $config;
 	protected string $path = JVB_DIR.'/build/feed';
 
 	public function __construct()
 	{
 		// Initialize cache with connections
-		$this->cache = CacheManager::for('feed_block', WEEK_IN_SECONDS);
-		// Set up cache connections for all feed content types
-		$this->setupCacheConnections();
+		$this->cache = Cache::for('feed_block', WEEK_IN_SECONDS);
+
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 
 		add_action('init', [$this, 'registerBlock']);
 	}
 
-	/**
-	 * Set up cache connections for feed content
-	 */
-	protected function setupCacheConnections(): void
-	{
-		// Connect to all content types that show in feed
-		$contentTypes = Features::getTypesWithFeature('show_feed', 'content');
-		foreach ($contentTypes as $type) {
-			CacheManager::for('feed_content')->connectTo('post', $type);
-		}
-
-		// Connect to all taxonomies that show in feed
-		$taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
-		foreach ($taxonomies as $tax) {
-			CacheManager::for('feed_taxonomy')->connectTo('taxonomy', $tax);
-		}
-	}
-
 	public function registerBlock()
 	{
 		register_block_type($this->path, [
@@ -131,7 +112,7 @@
 
 		$this->config = $this->buildParams($attributes);
 		return $this->cache->remember(
-			$this->config,
+			$this->cache->generateKey($this->config),
 			function() {
 				return $this->renderBlock();
 			}
@@ -317,19 +298,45 @@
 							</label>
 
 							<input type="radio" id="order-date" class="btn" name="orderby" value="date" data-filter="orderby" checked>
-							<label for="order-date" title="Order by Date" class="row">
+							<label for="order-date" title="Order by Date Created" class="row">
 								<?= jvbIcon('calendar', ['title' => 'Date']) ?>
-								<span class="label">Date</span>
+								<span class="label">Date Created</span>
 							</label>
 
+							<input type="radio" id="order-modified" class="btn" name="orderby" value="modified" data-filter="orderby">
+							<label for="order-modified" title="Order by Date Modified" class="row">
+								<?= jvbIcon('clock-clockwise') ?>
+								<span class="label">Date Modified</span>
+							</label>
+
+							<?php
+								$custom = [];
+								foreach ($this->getContent() as $content) {
+									$config = JVB_CONTENT[$content]??JVB_TAXONOMY[$content]??JVB_USER[$content]??false;
+									if ($config && array_key_exists('custom_order', $config)) {
+										$custom = array_merge_recursive($custom, $config['custom_order']);
+									}
+								}
+								foreach ($custom as $slug => $conf) {
+									?>
+									<input type="radio" id="order-<?=$slug?>" class="btn" name="orderby" value="<?=$slug?>" data-for="<?=$conf['for']?>" data-filter="orderby">
+									<label for="order-<?=$slug?>" title="<?= $conf['label']?>" class="row">
+										<?= jvbIcon($conf['icon']) ?>
+										<span class="label"><?=$conf['label']?></span>
+									</label>
+									<?php
+								}
+								$custom = implode(',', array_keys($custom));
+							?>
 							<input type="radio" id="order-random" class="btn" name="orderby" value="random" data-filter="orderby">
 							<label for="order-random" title="Random Order" class="row">
 								<?= jvbIcon('shuffle') ?>
 								<span class="label">Random</span>
 							</label>
+
 						</div>
 
-						<div class="order-direction filter-group row start w-full" data-for-order="date,title">
+						<div class="order-direction filter-group row start w-full" data-for-order="date,modified,title<?= $custom === '' ? '' : ','.$custom?>">
 							<span class="label">ORDER:</span>
 							<input type="radio" id="order-desc" class="btn" name="order" value="desc" data-filter="order" checked>
 							<label for="order-desc" title="Sort Descending (A-Z, 1-10)" class="row">
diff --git a/inc/blocks/FormBlock.php b/inc/blocks/FormBlock.php
index fd5fe29..3e8a892 100644
--- a/inc/blocks/FormBlock.php
+++ b/inc/blocks/FormBlock.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\blocks;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 use JVBase\managers\CloudflareTurnstile;
 use Exception;
@@ -19,8 +19,8 @@
  */
 class FormBlock
 {
-	protected static FormBlock|null $instance = null;
-	protected CacheManager $cache;
+	protected static ?FormBlock $instance = null;
+	protected Cache $cache;
 	protected array $forms;
 	protected string $form_contact;
 
@@ -36,7 +36,7 @@
 
 	public function __construct()
 	{
-		$this->cache = CacheManager::for('form_blocks', WEEK_IN_SECONDS);
+		$this->cache = Cache::for('forms', WEEK_IN_SECONDS);
 		// Initialize forms from filter
 		$this->forms = $this->registerForms();
 		$this->form_contact = apply_filters('jvb_form_contact', '');
@@ -131,16 +131,12 @@
 		}
 
 		$cache_key = $this->cache->generateKey($block);
-		$cached = $this->cache->get($cache_key);
-		$cached = false;
-		if ($cached) {
-			return $cached;
-		}
-
-		$rendered = $this->renderForm($form_type, $block);
-
-		$this->cache->set($cache_key, $rendered);
-		return $rendered;
+		return $this->cache->remember(
+			$cache_key,
+			function() use ($form_type, $block) {
+				return $this->renderForm($form_type, $block);
+			}
+		);
 	}
 
 	/**
diff --git a/inc/blocks/GlossaryBlock.php b/inc/blocks/GlossaryBlock.php
index 5681ce9..696c21d 100644
--- a/inc/blocks/GlossaryBlock.php
+++ b/inc/blocks/GlossaryBlock.php
@@ -1,9 +1,7 @@
 <?php
 namespace JVBase\blocks;
 
-use JVBase\managers\CacheManager;
-use JVBase\forms\TaxonomySelector;
-use JVBase\meta\MetaManager;
+use JVBase\managers\Cache;
 use WP_Block;
 use WP_Query;
 
@@ -13,7 +11,7 @@
 
 class GlossaryBlock
 {
-	protected CacheManager $cache;
+	protected Cache $cache;
 	protected string $config;
 	protected string $type;
 	protected string $path = JVB_DIR . '/build/glossary';
@@ -24,7 +22,7 @@
 
 	public function __construct()
 	{
-		$this->cache = CacheManager::for('glossary_terms', WEEK_IN_SECONDS)->connectTo('post', 'terms');
+		$this->cache = Cache::for('glossary_terms', WEEK_IN_SECONDS)->connect('post', true);
 		add_action('init', [ $this, 'registerBlock' ]);
 	}
 
@@ -89,7 +87,6 @@
 			'post_status' 		=> 'publish',
 			'orderby'			=> 'title',
 			'order'				=> 'asc',
-//			'fields'			=> 'ids'
 		]);
 		$glossary = [];
 		if ($posts->have_posts()) {
diff --git a/inc/blocks/MenuBlock.php b/inc/blocks/MenuBlock.php
index e52f2a2..88fd26a 100644
--- a/inc/blocks/MenuBlock.php
+++ b/inc/blocks/MenuBlock.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\blocks;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\forms\TaxonomySelector;
 use JVBase\meta\MetaManager;
 use WP_Block;
@@ -13,7 +13,7 @@
 
 class MenuBlock
 {
-    protected CacheManager $cache;
+    protected Cache $cache;
     protected string $config;
     protected string $type;
     protected string $path = JVB_DIR . '/build/menu';
@@ -27,7 +27,7 @@
 
     public function __construct()
     {
-        $this->cache = CacheManager::for('menu', WEEK_IN_SECONDS)->connectTo('post', 'menu_item');
+        $this->cache = Cache::for('menu', WEEK_IN_SECONDS)->connectTo('post', 'menu_item');
         add_action('init', [ $this, 'registerBlock' ]);
     }
 
@@ -57,16 +57,14 @@
 			return '';
 		}
 		$key = $this->cache->generateKey($this->params);
-        $cache = $this->cache->get($key);
-        if ($cache) {
-            return $cache;
-        }
-
-        ob_start();
-        $this->renderBlock();
-        $content = ob_get_clean();
-        $this->cache->set($key, $content);
-        return $content;
+		return $this->cache->remember(
+			$key,
+			function() {
+				ob_start();
+				$this->renderBlock();
+				return ob_get_clean();
+			}
+		);
     }
 
     protected function renderBlock():void
diff --git a/inc/blocks/SummaryBlock.php b/inc/blocks/SummaryBlock.php
index 84d99b3..c236a7c 100644
--- a/inc/blocks/SummaryBlock.php
+++ b/inc/blocks/SummaryBlock.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\blocks;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\forms\TaxonomySelector;
 use WP_Block;
 
@@ -11,7 +11,7 @@
 
 class SummaryBlock
 {
-    protected CacheManager $cache;
+    protected Cache $cache;
     protected string $config;
     protected string $type;
     protected string $path = JVB_DIR . '/build/summary';
@@ -23,10 +23,10 @@
 
     public function __construct()
     {
-        $this->cache = CacheManager::for('summary_block', WEEK_IN_SECONDS);
+        $this->cache = Cache::for('summary_block', WEEK_IN_SECONDS);
         add_action('init', [ $this, 'registerBlock' ]);
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
     }
 
diff --git a/inc/blocks/TimelineBlock.php b/inc/blocks/TimelineBlock.php
index 90cf469..5c21fdd 100644
--- a/inc/blocks/TimelineBlock.php
+++ b/inc/blocks/TimelineBlock.php
@@ -1,12 +1,10 @@
 <?php
 namespace JVBase\blocks;
 
-use JVBase\managers\CacheManager;
-use JVBase\forms\TaxonomySelector;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 use JVBase\utility\Features;
 use WP_Block;
-use WP_Query;
 
 if (!defined('ABSPATH')) {
     exit; // Exit if accessed directly
@@ -14,7 +12,7 @@
 
 class TimelineBlock
 {
-    protected CacheManager $cache;
+    protected Cache $cache;
     protected string $config;
     protected string $type;
     protected string $path = JVB_DIR . '/build/timeline';
@@ -31,9 +29,9 @@
 
     public function __construct()
     {
-        $this->cache = CacheManager::for('timelines', WEEK_IN_SECONDS)->connectTo('post', 'timeline');
+        $this->cache = Cache::for('timelines', WEEK_IN_SECONDS)->connect('post');
 		if (JVB_TESTING){
-			$this->cache->clear();
+			$this->cache->flush();
 		}
         add_action('init', [ $this, 'registerBlock' ]);
 		add_action('wp_footer', 'jvbRenderGallery');
@@ -122,7 +120,7 @@
 							?>
 
 							<?= $open ?>
-							<a href="<?=$link?>" rel="tag"><?=$term->name?></a>
+							<a href="<?=$link?>" rel="tag"><?=html_entity_decode($term->name)?></a>
 							<?= $close ?>
 						<?php }
 						if ($many) { echo '</ul>'; }
@@ -140,8 +138,10 @@
 				<?= jvbFormatImage(get_post_thumbnail_id($this->parentID), 'tiny', 'large', false) ?>
 			</div>
 			<div class="after">
+				<?php if (!empty($this->children)) :?>
 				<h3>After <?=$this->total?> Treatment<?= $this->total > 1 ? 's' : '' ?></h3>
 				<?= jvbFormatImage(get_post_thumbnail_id($this->children[count($this->children)-1]), 'tiny', 'large', false) ?>
+				<?php endif; ?>
 			</div>
 		</section>
 		<section id="info">
@@ -221,7 +221,7 @@
 		$out = '<ul class="term-list">';
 		foreach ($timeline as $term) {
 			$link = get_term_link($term->term_id, BASE.'timeline');
-			$out .= '<li><a href="'.$link.'" rel="tag" title="See more progressions at this timeline">'.jvbIcon(JVB_TAXONOMY['timeline']['icon']??'hourglass').$term->name.'</a><small>from the last treatment</small></li>';
+			$out .= '<li><a href="'.$link.'" rel="tag" title="See more progressions at this timeline">'.jvbIcon(JVB_TAXONOMY['timeline']['icon']??'hourglass').html_entity_decode($term->name).'</a><small>after the treatment</small></li>';
 		}
 		$out .='</ul>';
 		return $out;
diff --git a/inc/forms/PostSelector.php b/inc/forms/PostSelector.php
index 0df418f..6f4ed7c 100644
--- a/inc/forms/PostSelector.php
+++ b/inc/forms/PostSelector.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\forms;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use WP_REST_Request;
 use WP_REST_Response;
 use WP_Query;
@@ -18,12 +18,12 @@
 {
 	protected string $post_type;
 	protected array $config;
-	protected CacheManager $cache;
+	protected Cache $cache;
 
 	public function __construct(string $post_type, array $config = [])
 	{
 		$this->post_type = $post_type;
-		$this->cache = CacheManager::for(jvbNoBase($post_type), WEEK_IN_SECONDS);
+		$this->cache = Cache::for(jvbNoBase($post_type), WEEK_IN_SECONDS)->connect('post', true);
 
 		$this->config = wp_parse_args($config, [
 			'multiple' => true,
@@ -48,9 +48,6 @@
 	 */
 	public function render(array $selected = [], string $containerId = ''): string
 	{
-		// Mark that selectors are present for footer output
-		TaxonomySelector::markSelectorsPresent();
-
 		// Process selected posts
 		$processedSelected = $this->processSelectedPosts($selected);
 
diff --git a/inc/forms/PostSelectorOld.php b/inc/forms/PostSelectorOld.php
deleted file mode 100644
index 333ecec..0000000
--- a/inc/forms/PostSelectorOld.php
+++ /dev/null
@@ -1,213 +0,0 @@
-<?php
-namespace JVBase\forms;
-
-use JVBase\forms\TaxonomySelector;
-use WP_REST_Request;
-use WP_REST_Response;
-use WP_Term;
-use WP_Query;
-
-if (!defined('ABSPATH')) {
-	exit; // Exit if accessed directly
-}
-class PostSelectorOld extends TaxonomySelector
-{
-	protected string $post_type;
-
-	public function __construct(int $id, string $post_type, array $config = [])
-	{
-		$this->post_type = $post_type;
-		parent::__construct($id, '', $config); // Empty taxonomy as we're using post type
-	}
-
-	protected function getAvailableTerms():array
-	{
-		$args = [
-			'post_type' => $this->post_type,
-			'posts_per_page' => 20, // Initial load amount
-			'orderby' => 'title',
-			'order' => 'ASC',
-			'fields' => 'id=>name'
-		];
-
-		// Add shop exclusion if shop_id is set
-		if (!empty($this->config['shop_id'])) {
-			$args['tax_query'] = [[
-				'taxonomy' => BASE.'shop',
-				'terms' => $this->config['shop_id'],
-				'operator' => 'NOT IN'
-			]];
-		}
-
-		$posts = get_posts($args);
-		return is_wp_error($posts) ? [] : array_combine(
-			array_map(function ($post) {
-				return $post->ID;
-			}, $posts),
-			array_map(function ($post) {
-				return $post->post_title;
-			}, $posts)
-		);
-	}
-
-	public function render(array $selected = []):string
-	{
-		$wrapper_classes = $this->getWrapperClasses();
-
-		ob_start();
-		?>
-		<div class="<?= esc_attr($wrapper_classes); ?>"
-			 id="<?= esc_attr($this->id); ?>"
-			 data-post-type="<?= esc_attr($this->post_type); ?>"
-			 data-config='<?= esc_attr(wp_json_encode($this->getFrontendConfig())); ?>'>
-
-			<div class="selector-wrapper">
-				<?php $this->renderSelectedItems($selected); ?>
-
-				<dialog class="selector-modal">
-					<div class="wrap col">
-						<header class="modal-header">
-							<h3><?= esc_html($this->config['title'] ?? 'Search Artists'); ?></h3>
-							<button type="button" class="cancel" aria-label="Close">×</button>
-						</header>
-
-
-						<div class="items-container">
-							<?php $this->renderSelectableItems(); ?>
-							<div class="scroll-sentinel"></div>
-						</div>
-
-						<?php if ($this->config['modal']) : ?>
-							<div class="pending-section" hidden>
-								<h4>Pending Approvals</h4>
-								<div class="pending-list" role="list"></div>
-							</div>
-						<?php endif; ?>
-					</div>
-					<?= jvbSearch() ?>
-				</dialog>
-			</div>
-		</div>
-		<?php
-		return ob_get_clean();
-	}
-
-	protected function renderSelectedItems(array $selected = []):void
-	{
-		if (empty($selected)) {
-			echo '<div class="selected-items"></div>';
-			return;
-		}
-
-		echo '<div class="selected-items">';
-		foreach ($selected as $id => $title) {
-			printf(
-				'<div class="selected-item" data-id="%s">
-                    <span class="item-name">%s</span>
-                    <button type="button"
-                            class="remove-item"
-                            aria-label="Remove %s">×</button>
-                </div>',
-				esc_attr($id),
-				esc_html($title),
-				esc_attr($title)
-			);
-		}
-		echo '</div>';
-	}
-
-	public function renderSelectableItems():void
-	{
-		$posts = $this->getAvailableTerms();
-
-		echo '<div class="wrap"><ul class="flat items">';
-		foreach ($posts as $ID => $name) {
-			echo '<li>';
-			?>
-			<input id="<?= $this->id.'-'.esc_attr($ID); ?>"
-				   type="<?= $this->config['multiple'] ? 'checkbox' : 'radio'; ?>"
-				   name="<?= esc_attr($this->id); ?>"
-				   value="<?= esc_attr($ID); ?>">
-			<label class="selectable-item" for="<?= $this->id.'-'.esc_attr($ID); ?>">
-				<?= esc_html($name); ?>
-			</label>
-			<?php
-			echo '</li>';
-		}
-
-		echo '</ul></div>';
-	}
-
-	public function handleArtistSearch(WP_REST_Request $request):WP_REST_Response
-	{
-		$query = sanitize_text_field($request->get_param('query'));
-		$page = (int)$request->get_param('page') ?: 1;
-		$per_page = 30;
-
-		$args = [
-			'post_type' => BASE.'artist',
-			'posts_per_page' => $per_page,
-			'paged' => $page,
-			'orderby' => 'title',
-			'order' => 'ASC',
-			's' => $query
-		];
-
-		// Add shop exclusion if shop_id is set
-		if (!empty($this->config['shop_id'])) {
-			$args['tax_query'] = [[
-				'taxonomy' => BASE.'shop',
-				'terms' => $this->config['shop_id'],
-				'operator' => 'NOT IN'
-			]];
-		}
-
-		$key = $this->cache->generateKey($args);
-		$cache = $this->cache->get($key);
-		if ($cache) {
-			return new WP_REST_Response($cache);
-		}
-
-		$posts = new WP_Query($args);
-		$results = [];
-
-		foreach ($posts->posts as $post) {
-			$city_terms = wp_get_object_terms($post->ID, BASE.'city');
-			$city = !empty($city_terms) ? $city_terms[0]->name : '';
-
-			$results[] = [
-				'id' => $post->ID,
-				'title' => $post->post_title,
-				'thumbnail' => get_the_post_thumbnail_url($post->ID, 'thumbnail'),
-				'city' => $city,
-				'url' => get_permalink($post->ID)
-			];
-		}
-
-		$return = [
-			'results' => $results,
-			'hasMore' => $posts->max_num_pages > $page,
-			'total' => $posts->found_posts
-		];
-		$this->cache->set($key, $return);
-		return new WP_REST_Response($return);
-	}
-
-	protected function getWrapperClasses():string
-	{
-		$classes = [
-			'jvb-selector',
-			'selector-' . $this->post_type,
-			'post-selector'
-		];
-
-		if ($this->config['multiple'] ?? false) {
-			$classes[] = 'multiple';
-		}
-		if ($this->config['modal'] ?? false) {
-			$classes[] = 'has-modal';
-		}
-
-		return implode(' ', $classes);
-	}
-}
diff --git a/inc/forms/TaxonomySelector.php b/inc/forms/TaxonomySelector.php
index 9409b61..bcc5a3b 100644
--- a/inc/forms/TaxonomySelector.php
+++ b/inc/forms/TaxonomySelector.php
@@ -64,14 +64,14 @@
 	 */
 	public static function getTermPath(WP_Term $term, bool $returnArray = false): string|array {
 		if (!is_taxonomy_hierarchical($term->taxonomy)) {
-			return $term->name;
+			return html_entity_decode($term->name);
 		}
 
 		$path = [];
 		$currentTerm = $term;
 
 		while ($currentTerm) {
-			array_unshift($path, $currentTerm->name);
+			array_unshift($path, html_entity_decode($currentTerm->name));
 
 			if ($currentTerm->parent) {
 				$currentTerm = get_term($currentTerm->parent);
@@ -342,7 +342,7 @@
 			<span><?= esc_html($termPath) ?></span>
 			<button type="button"
 					class="remove-term row"
-					aria-label="Remove <?= esc_attr($term->name) ?>">
+					aria-label="Remove <?= html_entity_decode($term->name) ?>">
 				<?= jvbIcon('x') ?>
 			</button>
 		</div>
diff --git a/inc/forms/TaxonomySelectorOld.php b/inc/forms/TaxonomySelectorOld.php
deleted file mode 100644
index 7da3336..0000000
--- a/inc/forms/TaxonomySelectorOld.php
+++ /dev/null
@@ -1,733 +0,0 @@
-<?php
-namespace JVBase\forms;
-
-use JVBase\managers\CacheManager;
-use JVBase\managers\IconsManager;
-use WP_REST_Request;
-use WP_REST_Response;
-use WP_Term;
-use WP_Query;
-
-if (!defined('ABSPATH')) {
-    exit; // Exit if accessed directly
-}
-
-/**
- * Base class for taxonomy selector components
- * Provides foundation for StyleSelector, ThemeSelector, etc.
- */
-class TaxonomySelectorOld
-{
-    protected string $id;
-    protected string $name;
-    protected string $taxonomyName;
-    protected IconsManager $icon;
-    protected string $plural;
-
-    protected string $taxonomy;
-    protected string $base;
-
-    protected array $selected = [];
-    protected CacheManager $cache;
-
-    /**
-     * @var array Configuration options
-     */
-    protected array $config;
-
-    /**
-     * Initialize selector
-     */
-    public function __construct(string $id, string $taxonomy, array $config = [])
-    {
-        $this->id = sanitize_key($id);
-        $this->taxonomy = jvbCheckBase($taxonomy);
-        $this->name = str_replace(BASE, '', $taxonomy);
-        $this->icon = IconsManager::getInstance();
-        $this->cache = CacheManager::for(jvbNoBase($taxonomy), WEEK_IN_SECONDS);
-
-        $this->base = $config['base'] ?? '';
-
-        $this->config = wp_parse_args($config, [
-            'name'            => $id,
-            'multiple'         => true,
-            'max_selections'=> 0,
-            'hierarchical'     => false,
-            'search'         => true,
-            'createNew'     => false,
-            'taxonomy'      => $this->taxonomy,
-            'required'         => false,
-            'group_by_family'=> false,
-            'placeholder'     => '',
-            'show_examples' => false,
-            'show_breadcrumbs'=> true,
-            'expand_siblings'=> true,
-            'show_popularity'=> false,
-            'no_results'     => 'Nothing here.',
-            'base'          => $this->base,
-//            'association' => array(),
-            'common'   => array(),
-            'types'            => '', //for feed block implementation
-            'hidden'        => false,
-            'label'            => '',
-            'renderTemplates'            => true,
-        ]);
-
-
-		add_action('wp_footer', [$this, 'outputDialog']);
-
-
-        $tax = get_taxonomy($this->taxonomy);
-
-        $this->plural = JVB_TAXONOMY[$taxonomy]['plural'];
-        $this->taxonomyName = JVB_TAXONOMY[$taxonomy]['singular'];
-    }
-
-    /**
-     * Render selector component
-     * @param array $selected
-     *
-     * @return string
-     */
-    public function render(array $selected = []):string
-    {
-        $this->selected = $selected;
-
-        $wrapper_classes = $this->getWrapperClasses();
-
-        ob_start();
-        ?>
-        <div class="<?= esc_attr($wrapper_classes); ?>"
-             id="<?= esc_attr($this->id); ?>"
-             data-taxonomy="<?= esc_attr($this->name); ?>"
-             data-config='<?= esc_attr(wp_json_encode($this->getFrontendConfig($selected))); ?>'>
-
-            <div class="selector-wrapper">
-                <?php $this->renderSelectedItems($selected); ?>
-            </div>
-            <input type="hidden" name="<?= $this->name ?>">
-        </div>
-        <?php
-        return ob_get_clean();
-    }
-
-    /**
-     * @param array $selected
-     *
-     * @return string
-     */
-    public function renderFeed(array $selected = []):string
-    {
-
-        $this->selected = $selected;
-
-        $wrapper_classes = $this->getWrapperClasses();
-        $icon = new JVBIcons();
-        ob_start();
-        ?>
-        <div class="<?= esc_attr($wrapper_classes); ?> type-filter taxonomy-filter"
-             id="<?= esc_attr($this->id); ?>"
-             data-taxonomy="<?= $this->name ?>"
-             data-for="<?= implode(',', $this->config['types']) ?>"
-            <?= $this->config['hidden'] ?>>
-
-            <button type="button" class="filter-toggle row" title="Filter by <?= $this->config['label'] ?>"
-                    aria-expanded="false"
-                    aria-controls="filter-dropdown-<?= $this->name ?>">
-                <?= $icon->getIcon($this->name, ['title'=>$this->config['label']]) ?>
-                <?= $this->config['label'] ?>
-            </button>
-            <dialog id="selector-modal filter-dropdown-<?= $this->name ?>"
-                    class="filter-dropdown"
-                    data-taxonomy="<?= $this->name ?>">
-                <?php $this->renderModalContent(); ?>
-            </dialog>
-
-            <form class="selected-terms" hidden></form>
-        </div>
-        <input type="hidden" name="<?= $this->name ?>">
-        <?php
-        return ob_get_clean();
-    }
-
-    /**
-     * Handle artist search requests
-     */
-    public function handleArtistSearch(WP_REST_Request $request):WP_REST_Response
-    {
-        $query = sanitize_text_field($request->get_param('query'));
-        $page = (int)$request->get_param('page') ?: 1;
-        $per_page = 10;
-
-        $args = [
-            'post_type' => BASE.'artist',
-            'posts_per_page' => $per_page,
-            'paged' => $page,
-            'orderby' => 'title',
-            'order' => 'ASC',
-            'post_status' => 'publish',
-            's' => $query
-        ];
-
-        // If shop_id provided, exclude artists already in shop
-        $shop_id = $request->get_param('shop_id');
-        if ($shop_id) {
-            $existing_artists = get_posts([
-                'post_type' => BASE.'artist',
-                'posts_per_page' => -1,
-                'fields' => 'ids',
-                'tax_query' => [[
-                    'taxonomy' => BASE.'shop',
-                    'terms' => $shop_id
-                ]]
-            ]);
-
-            if (!empty($existing_artists)) {
-                $args['post__not_in'] = $existing_artists;
-            }
-        }
-
-        $key = $this->cache->generateKey($args);
-        $cache = $this->cache->get($key);
-        if ($cache) {
-            return new WP_REST_Response($cache);
-        }
-
-        $posts = get_posts($args);
-        $results = [];
-
-        foreach ($posts as $post) {
-            $city_terms = wp_get_object_terms($post->ID, BASE.'city');
-            $city = !empty($city_terms) ? $city_terms[0]->name : '';
-
-            $results[] = [
-                'id' => $post->ID,
-                'title' => $post->post_title,
-                'thumbnail' => get_the_post_thumbnail_url($post->ID, 'thumbnail'),
-                'city' => $city,
-                'url' => get_permalink($post->ID)
-            ];
-        }
-
-        $result =  [
-            'results' => $results,
-            'hasMore' => count($posts) === $per_page
-        ];
-        $this->cache->set($key, $result);
-        return new WP_REST_Response($result);
-    }
-
-
-    /**
-     * Get wrapper CSS classes
-     * @return string
-     */
-    protected function getWrapperClasses():string
-    {
-        $classes = [
-            'jvb-selector',
-            'selector-' . strtolower($this->name)
-        ];
-
-        if ($this->config['multiple']) {
-            $classes[] = 'multiple';
-        }
-        if ($this->config['hierarchical']) {
-            $classes[] = 'hierarchical';
-        }
-        if ($this->config['required']) {
-            $classes[] = 'required';
-        }
-
-        return implode(' ', $classes);
-    }
-
-    /**
-     * Get configuration for frontend JavaScript
-     * @return array
-     */
-    protected function getFrontendConfig(array $selected = []):array
-    {
-        $out = [
-//            'name'        => $this->config['name'],
-            'multiple' => $this->config['multiple'],
-            'maxSelections' => $this->config['max_selections'],
-//            'hierarchical' => $this->config['hierarchical'],
-            'search' => $this->config['search'],
-            'createNew' => (bool)$this->config['createNew'],
-            'required' => $this->config['required'],
-//            'placeholder' => $this->config['placeholder'],
-            'noResults' => $this->config['no_results'],
-//            'group_by_family' => $this->config['group_by_family'],
-//            'labels' => $this->getLabels(),
-            'base'  => $this->config['base'],
-            'selected' => $this->getSelectedData($selected),
-//            'values' => $this->getAvailableTerms(),
-//            'hierarchy' => [],
-//            'breadcrumbs' => [],
-//            'association'   => $this->config['association']
-//            'common'    => $this->config['common']
-        ];
-        if ($this->config['hierarchical']) {
-            $out['hierarchy'] = $this->getTermHierarchy();
-            $out['breadcrumbs'] = $this->getBreadcrumbData();
-        }
-        return $out;
-    }
-
-
-    /**
-     * Get theme hierarchy data
-     * @param int $parent ID of parent
-     *
-     * @return array
-     */
-    protected function getTermHierarchy(int $parent = 0):array
-    {
-        $terms = get_terms([
-            'taxonomy' => $this->taxonomy,
-            'parent' => $parent,
-            'hide_empty' => false
-        ]);
-
-        if (is_wp_error($terms)) {
-            return [];
-        }
-
-        $hierarchy = [];
-        foreach ($terms as $term) {
-            $children = $this->getTermHierarchy($term->term_id);
-            $hierarchy[] = [
-                'id' => $term->term_id,
-                'name' => $term->name,
-                'parent' => $term->parent,
-                'children' => $children
-            ];
-        }
-
-        return $hierarchy;
-    }
-    /**
-     * Get theme breadcrumb data
-     * @return array
-     */
-    protected function getBreadcrumbData():array
-    {
-        $breadcrumbs = [];
-        foreach ($this->selected as $term_id => $title) {
-            $breadcrumbs[$term_id] = $this->getTermAncestors($term_id);
-        }
-        return $breadcrumbs;
-    }
-
-    /**
-     * Get term ancestors with names
-     * @param int $term_id
-     *
-     * @return array
-     */
-    protected function getTermAncestors(int $term_id):array
-    {
-        $cache = $this->cache->get('term-ancestors-'.$term_id);
-        if ($cache) {
-            return $cache;
-        }
-        $ancestors = get_ancestors($term_id, $this->taxonomy, 'taxonomy');
-        $path = [];
-        foreach (array_reverse($ancestors) as $ancestor_id) {
-            $term = get_term($ancestor_id, $this->taxonomy);
-            if ($term && !is_wp_error($term)) {
-                $path[] = [
-                    'id' => $term->term_id,
-                    'name' => $term->name
-                ];
-            }
-        }
-        $this->cache->set('term-ancestors-'.$term_id, $path);
-        return $path;
-    }
-
-    /**
-     * @return array
-     */
-    protected function getAvailableTerms():array
-    {
-        $cache = $this->cache->get($this->taxonomy . '-terms');
-        if ($cache) {
-            return $cache;
-        }
-        $terms = get_terms([
-            'taxonomy' => $this->taxonomy,
-            'hide_empty' => false,
-            'fields' => 'id=>name'
-        ]);
-
-        $result = is_wp_error($terms) ? [] : $terms;
-        $this->cache->set($this->taxonomy . '-terms', $result);
-        return $result;
-    }
-
-    /**
-     * Get text labels
-     * @return array
-     */
-    protected function getLabels():array
-    {
-        return [
-            'search' => __('Search...', 'jvb'),
-            'select' => __('Select', 'jvb'),
-            'selected' => __('Selected', 'jvb'),
-            'remove' => __('Remove', 'jvb'),
-            'clear' => __('Clear', 'jvb'),
-            'createNew' => __('Create New', 'jvb'),
-            'loading' => __('Loading...', 'jvb'),
-            'saving' => __('Saving...', 'jvb'),
-            'parent_themes' => __('Parent Themes', 'jvb'),
-            'sub_themes' => __('Sub-themes', 'jvb'),
-            'related_themes' => __('Related Themes', 'jvb'),
-            'popular_combinations' => __('Popular Combinations', 'jvb'),
-            'view_examples' => __('View Examples', 'jvb'),
-            'back_to_parent' => __('Back to Parent', 'jvb')
-        ];
-    }
-
-    /**
-     * Render selected items
-     * @return void
-     */
-    protected function renderSelectedItems(array $selected = []):void
-    {
-        if (empty($selected) && empty($this->selected)) {
-            echo '<div class="selected-items"></div>';
-            return;
-        }
-
-        $selected = empty($selected) ? $this->selected : $selected;
-
-        $out = '<div class="selected-items">';
-        foreach ($selected as $ID) {
-            $term = get_term((int)$ID, $this->taxonomy);
-
-            if (!$term || is_wp_error($term)) {
-                continue;
-            }
-
-            $out .= $this->renderSelectedItem($term);
-        }
-        $out .= '</div>';
-        echo $out;
-    }
-
-    /**
-     * Render single selected item
-     * @param WP_Term $term
-     *
-     * @return string
-     */
-    protected function renderSelectedItem(WP_Term $term):string
-    {
-
-        return '<div class="selected-item" data-id="'.$term->term_id.'">
-            <span class="item-name">'.$term->name.'</span>
-            <button type="button"
-                    class="remove-item"
-                    aria-label="Remove" title="Remove '.$term->name.'">×</button>
-        </div>';
-    }
-
-    /**
-     * Render modal content
-     * @return void
-     */
-    protected function renderModalContent():void
-    {
-        ?>
-        <div class="modal-content">
-            <header class="modal-header">
-                <h3><?= esc_html($this->getModalTitle()); ?></h3>
-            </header>
-            <div class="actions row">
-                <button type="button" class="cancel" aria-label="Close">×</button>
-            </div>
-            <div class="selected-items">
-            </div>
-
-            <div class="items-wrap">
-                <?php if (!empty($this->config['common'])) : ?>
-                    <details class="favourite-terms" open>
-                        <summary class="title row btw">Your Go Tos: </summary>
-                        <ul></ul>
-                    </details>
-                <?php endif; ?>
-                <p class="pagination-info"></p>
-                <nav class="term-navigation row"><button type="button" class="back-to-parent" hidden><?=$this->icon->getIcon('back')?></button></nav>
-                <ul class="items-container"></ul>
-				<p class="loading"> { <span>loading items</span> } </p>
-                <div class="scroll-sentinel"></div>
-            </div>
-
-
-
-            <?php if ($this->config['search'] || $this->config['createNew']) : ?>
-                <div class="search-wrapper">
-                    <div class="search-bar">
-
-                        <?php if ($this->config['search']) :
-                            echo jvbSearch('Search '.$this->plural.'...');
-                        endif; ?>
-                    </div>
-                    <?php if ($this->config['createNew']) : ?>
-						<details class="create-new-term">
-							<summary class="row btw">Add new <?= $this->taxonomyName ?></summary>
-							<div class="loader"></div>
-							<div class="loading-message create-term" hidden>
-								<span id="typed-text"></span>
-								<span class="cursor">|</span>
-							</div>
-							<div class="create-new-term-section">
-								<?= (jvbSiteVerifiesUsers()) ? '<p class="suggestion-prompt">
-                                Not finding what you\'re looking for?<br>
-                                Suggest a new <span>'.strtolower($this->taxonomyName).'</span>.</p>' : '' ?>
-
-
-									<div class="form-row name-row">
-										<input type="text"
-											   name="term_name"
-											   placeholder="Enter new <?=$this->taxonomyName?> name"
-											   required>
-									</div>
-
-									<div class="form-row parent-row toggle">
-										<label for="select_parent">Nest new <?= $this->taxonomyName ?> under one of these?</label>
-										<select id="select_parent" name="select_parent">
-											<option value="0"> . . . </option>
-										</select>
-									</div>
-
-									<button type="button" class="submit-term">
-										<?= $this->icon->getIcon('add')?>
-										<span><?= (jvbSiteHasTermApproval()) ? 'Suggest '.$this->taxonomyName : 'Create '.$this->taxonomyName?></span>
-									</button>
-							</div>
-						</details>
-                    <?php endif; ?>
-                </div>
-            <?php endif; ?>
-        </div>
-        <?php if (jvbCheck('renderTemplates', $this->config)): ?>
-        <template class="loadingItems">
-			<p>{ <span>loading items</span> }</p>
-        </template>
-        <template class="noResults">
-			<p>{ <span>nothing found</span> }</p>
-        </template>
-        <template class="termListItem">
-            <li>
-                <input type ="<?=($this->config['multiple']) ? 'checkbox' : 'radio'?>">
-                <label>
-                    <span class="term-name"></span>
-                </label>
-            </li>
-        </template>
-        <template class="termChildrenToggle">
-            <button type="button" class="toggle-children" aria-expanded="false">
-                <?=$this->icon->getIcon('add')?>
-            </button>
-        </template>
-        <template class="selectedTerm">
-            <div class="selected-item">
-                <span class="item-name"></span>
-                <button type="button" class="remove-item"><?=$this->icon->getIcon('close')?></button>
-            </div>
-        </template>
-        <template class="termBreadcrumb">
-            <button type="button" class="path-level"></button>
-        </template>
-        <?php
-        endif;
-    }
-
-    /**
-     * Get modal title
-     * @return string
-     */
-    protected function getModalTitle():string
-    {
-        $tax_obj = get_taxonomy($this->taxonomy);
-        return sprintf(
-            __('Select %s', 'jvb'),
-            $tax_obj ? $tax_obj->labels->name : __('Items', 'jvb')
-        );
-    }
-
-
-
-
-    /**
-     * Get popularity label
-     * @param int $count
-     *
-     * @return string
-     */
-    protected function getPopularityLabel(int $count):string
-    {
-        switch (true) {
-            case $count > 100:
-                return __('Very Popular', 'jvb');
-            case $count > 50:
-                return __('Popular', 'jvb');
-            case $count > 10:
-                return __('Common', 'jvb');
-            default:
-                return __('Unique', 'jvb');
-        }
-    }
-    /**
-     * Render single selectable item
-     * @param WP_Term $term
-     * @param bool $selected
-     *
-     * @return void
-     */
-    protected function renderSelectableItem(WP_Term $term, bool $selected = false):void
-    {
-        ?>
-        <input id="<?=$this->base?><?= strtolower($this->taxonomyName).'-'.esc_attr($term->term_id); ?>"
-               type="<?= $this->config['multiple'] ? 'checkbox' : 'radio'; ?>"
-               name="<?=$this->base?><?=strtolower($this->taxonomyName)?><?= esc_attr($this->id); ?>"
-               value="<?= esc_attr($term->term_id); ?>"
-            <?php checked($selected); ?>>
-        <label class="selectable-item" for="<?=$this->base?><?= strtolower($this->taxonomyName).'-'.esc_attr($term->term_id); ?>">
-            <span><?= esc_html($term->name); ?></span>
-        </label>
-        <?php
-    }
-    /**
-     * Get term metadata
-     */
-//    protected function get_term_meta($term) {
-//        if (!function_exists('carbon_get_term_meta')) {
-//            return [];
-//        }
-//
-//        return [
-//            'examples' => $this->get_term_examples($term->term_id),
-//            'popular_combinations' => $this->get_term_combinations($term->term_id),
-//            'related_themes' => carbon_get_term_meta($term->term_id, 'jvb_related_themes') ?: []
-//        ];
-//    }
-
-    /**
-     * Get theme examples
-     */
-//    protected function get_term_examples($term_id, $limit = 4) {
-//        $examples = [];
-//        $posts = get_posts([
-//            'post_type' => "e_{$this->context}",
-//            'posts_per_page' => $limit,
-//            'tax_query' => [[
-//                'taxonomy' => $this->taxonomy,
-//                'terms' => $term_id
-//            ]],
-//            'orderby' => 'rand'
-//        ]);
-//
-//        foreach ($posts as $post) {
-//            if (has_post_thumbnail($post)) {
-//                $examples[] = [
-//                    'title' => get_the_title($post),
-//                    'thumbnail' => get_the_post_thumbnail_url($post, 'thumbnail'),
-//                    'full' => get_the_post_thumbnail_url($post, 'full')
-//                ];
-//            }
-//        }
-//
-//        return $examples;
-//    }
-    /**
-     * Render hidden inputs for form submission
-     * @return void
-     */
-    protected function renderHiddenInputs():void
-    {
-        if (empty($this->selected)) {
-            return;
-        }
-
-        foreach ($this->selected as $term_id => $name) {
-            printf(
-                '<input type="hidden" name="%s" value="%s">',
-                esc_attr($this->id),
-                esc_attr($term_id)
-            );
-        }
-    }
-
-    /**
-     * Get selected terms data for frontend
-     * @return array
-     */
-    protected function getSelectedData(array $selected = []):array
-    {
-        $data = [];
-        $selected = (empty($selected)) ? $this->selected : $selected;
-        foreach ($selected as $ID) {
-            $term = get_term((int) $ID, $this->taxonomy);
-            if (!$term || is_wp_error($term)) {
-                continue;
-            }
-
-            $data[] = [
-                'id' => $term->term_id,
-                'name' => $term->name,
-                'parent' => $term->parent
-            ];
-        }
-        return $data;
-    }
-
-    /**
-     * @var array Loading state messages
-     */
-    protected $loading_quips = [
-        "Making you look good...",
-        "Processing perfection...",
-        "Converting ink to pixels...",
-        "Teaching robots about art..."
-    ];
-
-    /**
-     * Get loading state HTML
-     * @return string
-     */
-    protected function getLoadingState():string
-    {
-        ob_start();
-        ?>
-        <div class="loading-overlay">
-            <div class="loading-spinner"></div>
-            <div class="loading-message">
-                <?= esc_html($this->getRandomQuip()); ?>
-            </div>
-        </div>
-        <?php
-        return ob_get_clean();
-    }
-
-    /**
-     * Get random loading message
-     * @return string
-     */
-    protected function getRandomQuip():string
-    {
-        return $this->loading_quips[array_rand($this->loading_quips)];
-    }
-
-	public function outputDialog() {
-		?>
-		<dialog class="selector-modal">
-			<?php $this->renderModalContent(); ?>
-		</dialog>
-		<?php
-	}
-}
diff --git a/inc/helpers/breadcrumbs.php b/inc/helpers/breadcrumbs.php
index 6c6e1f9..262a4d4 100644
--- a/inc/helpers/breadcrumbs.php
+++ b/inc/helpers/breadcrumbs.php
@@ -79,7 +79,7 @@
 
 	$url = get_term_link($term->term_id);
 	array_unshift($crumbs, [
-		'name' => $term->name,
+		'name' => html_entity_decode($term->name),
 		'url'  => $url,
 		'id'   => $term->term_id,
 	]);
diff --git a/inc/helpers/crud.php b/inc/helpers/crud.php
index fa0bdbb..7f4e8c8 100644
--- a/inc/helpers/crud.php
+++ b/inc/helpers/crud.php
@@ -4,6 +4,8 @@
 	exit;
 }
 
+use JVBase\managers\Cache;
+
 /**
  * Outputs the blocks of a CRUD management in backend
  * Mainly used in news.php so far
@@ -166,7 +168,7 @@
  */
 function jvbRenderDateFilter(string $content):string
 {
-    $cache = new JVBase\Managers\CacheManager('date_filter');
+    $cache = Cache::for('date_filter')->connect('post', true);
     $check = $cache->get($content);
     if ($check) {
         return $check;
diff --git a/inc/helpers/formatting.php b/inc/helpers/formatting.php
index ec0c40f..6f7f077 100644
--- a/inc/helpers/formatting.php
+++ b/inc/helpers/formatting.php
@@ -1,6 +1,7 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
+use JVBase\utility\Image;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -73,10 +74,9 @@
  */
 function jvbFormatRating(int $ID, JVBase\meta\MetaManager|null $meta = null):string
 {
-    $cache = CacheManager::for('rating', WEEK_IN_SECONDS)->connectTo('post')->connectTo('term');
+    $cache = Cache::for('rating', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
 
     $cached = $cache->get($ID);
-    $cached = false;
     if ($cached) {
         return $cached;
     }
@@ -137,26 +137,8 @@
  */
 function jvbImageData(int $imgID):array
 {
-    $cache = CacheManager::for('imageData', WEEK_IN_SECONDS)->connectTo('post');
-    $cached = $cache->get($imgID);
-    if ($cached) {
-        return $cached;
-    }
-
-    if (!wp_get_attachment_image($imgID, 'tiny')) {
-        return [];
-    }
-    $image = [
-        'tiny' 			=> wp_get_attachment_image_src($imgID, 'tiny')[0],
-        'small' 		=> wp_get_attachment_image_src($imgID, 'medium')[0],
-        'medium' 		=> wp_get_attachment_image_src($imgID, 'large')[0],
-        'large' 		=> wp_get_attachment_image_src($imgID, 'full')[0],
-        'image-alt-text'=> get_post_meta($imgID, '_wp_attachment_image_alt', true),
-		'image-title'	=> get_the_title($imgID),
-		'image-caption' => get_the_excerpt($imgID),
-    ];
-    $cache->set($imgID, $image);
-    return $image;
+	$image = new Image();
+	return $image->getImageData($imgID);
 }
 
 
@@ -233,3 +215,8 @@
 	}
 	return 'tel:+1'.$phoneNumber;
 }
+
+function jvbFormatString(string $string):string
+{
+	return html_entity_decode($string);
+}
diff --git a/inc/helpers/members.php b/inc/helpers/members.php
index c24ff84..26c7fa2 100644
--- a/inc/helpers/members.php
+++ b/inc/helpers/members.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 
 if (!defined('ABSPATH')) {
@@ -16,16 +16,14 @@
  */
 function jvbShareName(int $userID):string
 {
-    $cache = CacheManager::for('usernames');
-    $cached = $cache->get($userID);
-    if ($cached) {
-        return $cached;
-    }
-    $check = get_user_meta($userID, BASE.'notify', true);
-    $name = ($check) ? get_userdata($userID)->display_name : 'Someone';
-    $cache->set($userID, $name);
-
-    return $name;
+    $cache = Cache::for('usernames')->connect('user');
+	return $cache->remember(
+		$userID,
+		function() use ($userID) {
+			$check = get_user_meta($userID, BASE.'notify', true);
+			return ($check) ? get_userdata($userID)->display_name : 'Someone';
+		}
+	);
 }
 
 /**
@@ -35,38 +33,37 @@
  */
 function jvbGetUserByFirstName(string $first_name):WP_User|false
 {
-    $cache = CacheManager::for('userFirstname')->connectTo('user');
-    $cached = $cache->get($first_name)??false;
-    if ($cached) {
-        return get_userdata($cached);
-    }
-    $args = [
-        'post_type' => BASE . 'artist',
-        'posts_per_page' => 1,
-        'fields' => 'ids',
-        'meta_query' => [
-            [
-                'key' => BASE . 'first_name',
-                'value' => $first_name,
-                'compare' => '='
-            ]
-        ]
-    ];
+    $cache = Cache::for('userFirstname')->connect('user', true);
+	return $cache->remember(
+		$first_name,
+		function() use ($first_name) {
+			$args = [
+				'post_type' => BASE . 'artist',
+				'posts_per_page' => 1,
+				'fields' => 'ids',
+				'meta_query' => [
+					[
+						'key' => BASE . 'first_name',
+						'value' => $first_name,
+						'compare' => '='
+					]
+				]
+			];
+			$query = new WP_Query($args);
 
-    $query = new WP_Query($args);
-
-    if ($query->have_posts()) {
-        $post_id = $query->posts[0];
-        $user_id = get_post_meta($post_id, BASE . 'link', true);
-        $user = get_userdata($user_id)?:false;
-        $cached[$user_id] = $first_name;
-        $cache->set('user_first_names', $cached);
-		wp_reset_postdata();
-        return $user;
-    }
-	wp_reset_postdata();
-
-    return false;
+			if ($query->have_posts()) {
+				$post_id = $query->posts[0];
+				$user_id = get_post_meta($post_id, BASE . 'link', true);
+				$user = get_userdata($user_id)?:false;
+				if ($user) {
+					wp_reset_postdata();
+					return $user;
+				}
+			}
+			wp_reset_postdata();
+			return false;
+		}
+	);
 }
 
 /**
@@ -76,7 +73,7 @@
  */
 function jvbGetUserByDisplayName(string $display_name):WP_User|false
 {
-    $cache = CacheManager::for('user_displaynames')->connectTo('user');
+    $cache = Cache::for('displayNames')->connect('user', true);
     $cached = $cache->get($display_name)??false;
 
     if ($cached && is_int($cached)) {
@@ -115,7 +112,7 @@
 function jvbGetUsername(int $user_id):string
 {
     $key = 'user_display_names';
-    $cache = CacheManager::for('userNames', WEEK_IN_SECONDS)->connectTo('user');
+    $cache = Cache::for('userNames', WEEK_IN_SECONDS)->connect('user');
     $cached = $cache->get($user_id);
 
     if ($cached) {
@@ -156,7 +153,7 @@
         return false;
     }
 
-    $cache = CacheManager::for('artist', 3600)->connectTo('post');
+    $cache = Cache::for('artist', 3600)->connect('post');
 	$cached = $cache->get($userID);
 	if ($cached) {
 		return match ($return) {
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 67ade56..4930917 100644
--- a/inc/helpers/renderFields.php
+++ b/inc/helpers/renderFields.php
@@ -5,7 +5,7 @@
 }
 
 use JVBase\forms\TaxonomySelector;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaForm;
 use JVBase\meta\MetaManager;
 
@@ -65,7 +65,7 @@
  */
 function jvbRenderLinks(int $ID, MetaManager|null $meta = null):string
 {
-    $cache = CacheManager::for('user_links', WEEK_IN_SECONDS)->connectTo('post')->connectTo('taxonomy');
+    $cache = Cache::for('user_links', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
     $cached = $cache->get($ID);
     if ($cached) {
         return $cached;
@@ -141,7 +141,7 @@
  */
 function jvbRenderContactInfo(int $ID, MetaManager|null $meta = null):string
 {
-    $cache = CacheManager::for('contact', WEEK_IN_SECONDS)->connectTo('post')->connectTo('taxonomy');
+    $cache = Cache::for('contact', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy');
 
     $cached = $cache->get($ID);
     if($cached){
@@ -332,13 +332,13 @@
 			return '';
 		}
 	}
-	$cache = CacheManager::for($term->taxonomy);
-	$key = $term->term_id.'-link';
+	$cache = Cache::for($term->taxonomy.'_link')->connect('taxonomy');
+	$key = $term->term_id;
 	return $cache->remember(
 		$key,
 		function() use ($term) {
-			return '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="'.$term->name.'">'.
-			$term->name.
+			return '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="'.html_entity_decode($term->name).'">'.
+				html_entity_decode($term->name).
 			'</a>';
 		}
 	);
@@ -590,7 +590,7 @@
 		return '';
 	}
 
-	$cache = CacheManager::for('locations')->connectTo('taxonomy');
+	$cache = Cache::for('locations')->connect('taxonomy');
 	$key = $cache->generateKey($location);
 
 	$cached = false;
diff --git a/inc/helpers/time.php b/inc/helpers/time.php
index 6f94c49..7670726 100644
--- a/inc/helpers/time.php
+++ b/inc/helpers/time.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -141,18 +141,18 @@
  */
 function jvbRenderHours(int $ID, JVBase\Meta\MetaManager $meta):string
 {
-	$cache = CacheManager::for('hours-'.$ID, WEEK_IN_SECONDS)->connectTo('taxonomy');
-	$key = 'hours_display';
-	$cached = $cache->get($key);
+	$cache = Cache::for('hours_display', WEEK_IN_SECONDS)->connect('taxonomy')->connect('post')->connect('user');
+
+	$cached = $cache->get($ID);
 
 	if ($cached !== false) {
 		return $cached;
 	}
 
 	if (!$meta) {
-		if (term_exists((int)$ID)) {
+		if (term_exists($ID)) {
 			$type = 'term';
-		} elseif (get_post_status((int)$ID)) {
+		} elseif (get_post_status($ID)) {
 			$type = 'post';
 		} else {
 			$type = 'user';
@@ -185,7 +185,7 @@
 		$out .= '<p class="hours-notes"><small>' . implode(' • ', $notes) . '</small></p>';
 	}
 
-	$cache->set($key, $out);
+	$cache->set($ID, $out);
 	return $out;
 }
 
diff --git a/inc/helpers/ui.php b/inc/helpers/ui.php
index 8dac9d4..e97bbfa 100644
--- a/inc/helpers/ui.php
+++ b/inc/helpers/ui.php
@@ -86,8 +86,8 @@
 			<div class="actions row end">
 				<button class="retry" data-action="retry"><span>Retry</span><?= jvbIcon('arrows-clockwise')?></button>
 				<button class="cancel" data-action="cancel"><span>Cancel</span><?= jvbIcon('x-square')?></button>
+				<button class="refresh" data-action="refresh" title="Refresh to see changes"><span>Refresh</span><?= jvbIcon('arrows-clockwise')?></button>
 				<button class="dismiss" data-action="dismiss"><span>Dismiss</span><?= jvbIcon('eye-closed')?></button>
-
 			</div>
 		</div>
 	</template>
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/GoogleMyBusiness.php
index 6e823b2..2e97d01 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/GoogleMyBusiness.php
@@ -2,7 +2,6 @@
 namespace JVBase\integrations;
 
 use JVBase\meta\MetaManager;
-use JVBase\managers\CacheManager;
 use WP_Error;
 if (!defined('ABSPATH')) {
 	exit;
@@ -130,7 +129,7 @@
 		);
 
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 	}
 
@@ -2276,7 +2275,7 @@
 	{
 		try {
 			// Use the static method to clear the entire cache group
-			$this->cache->clear();
+			$this->cache->flush();
 			return true;
 
 		} catch (\Exception $e) {
diff --git a/inc/integrations/Helcim.php b/inc/integrations/Helcim.php
index b0b037d..ca486cd 100644
--- a/inc/integrations/Helcim.php
+++ b/inc/integrations/Helcim.php
@@ -1491,7 +1491,7 @@
 			update_user_meta($user->ID, BASE . '_helcim_customer_updated', current_time('mysql'));
 
 			// Clear cached customer data
-			$this->cache->delete('helcim_customer_' . $user->ID);
+			$this->cache->forget('helcim_customer_' . $user->ID);
 		}
 
 		return true;
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 9f7f851..08c375f 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -2,7 +2,7 @@
 namespace JVBase\integrations;
 
 use Exception;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\managers\UploadManager;
 use JVBase\meta\MetaManager;
 use JVBase\managers\ErrorHandler;
@@ -96,7 +96,7 @@
 	 * Caching Configuration
 	 */
 	protected ?string $cacheName = null;
-	protected CacheManager $cache;
+	protected Cache $cache;
 	protected array $cacheStrategy = [
 		'aggressive' => 3600,  // 1 hour for stable data (e.g., profile info)
 		'moderate' => 300,     // 5 minutes for semi-dynamic data (e.g., posts)
@@ -167,7 +167,7 @@
 	{
 		$this->cacheName = $this->cacheName ?: $this->service_name;
 		$this->userID = $userID;
-		$this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
+		$this->cache = Cache::for('integrations_' . $this->cacheName, $this->ttl);
 
 		// Load error stats from cache
 		$this->loadErrorStats();
@@ -669,7 +669,7 @@
 
 	protected function clearCache():array
 	{
-		$success = $this->cache->clear();
+		$success = $this->cache->flush();
 		return [
 			'success'	=> $success,
 		];
diff --git a/inc/integrations/Square.php b/inc/integrations/Square.php
index 121a74f..4b9dd2a 100644
--- a/inc/integrations/Square.php
+++ b/inc/integrations/Square.php
@@ -2572,7 +2572,7 @@
 			update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql'));
 
 			// Clear cached customer data
-			$this->cache->delete('square_customer_' . $user->ID);
+			$this->cache->forget('square_customer_' . $user->ID);
 		}
 
 		return true;
diff --git a/inc/integrations/Umami.php b/inc/integrations/Umami.php
index 0fa280b..3c89b76 100644
--- a/inc/integrations/Umami.php
+++ b/inc/integrations/Umami.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\integrations;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use WP_Error;
 use WP_Post;
 use Exception;
@@ -677,7 +677,7 @@
 		if ($data) {
 			// Clear cache for today
 			$cache_key = md5("analytics_{$today}_{$today}");
-			$this->cache->delete($cache_key);
+			$this->cache->forget($cache_key);
 
 			return [
 				'success' => true,
diff --git a/inc/managers/AdminPages.php b/inc/managers/AdminPages.php
index 1e42e8c..cf42870 100644
--- a/inc/managers/AdminPages.php
+++ b/inc/managers/AdminPages.php
@@ -98,10 +98,10 @@
 
 		switch ($action) {
 			case 'flush-all':
-				wp_cache_flush();
+				$total = Cache::flushAll();
 				return new \WP_REST_Response([
 					'success' => true,
-					'message' => 'All caches flushed successfully'
+					'message' => $total.' caches flushed successfully'
 				]);
 
 			case 'flush-cache':
@@ -113,7 +113,7 @@
 					], 400);
 				}
 
-				\JVBase\managers\CacheManager::invalidateAll($group);
+				Cache::for($group)?->flush();
 
 				return new \WP_REST_Response([
 					'success' => true,
@@ -134,17 +134,32 @@
 	public function handleIconAction(\WP_REST_Request $request): \WP_REST_Response
 	{
 		$action = sanitize_text_field($request->get_param('action'));
-		$source = sanitize_text_field($request->get_param('source') ?? 'icons'); // Add source param
-		$icons = \JVBase\managers\IconsManager::for($source);
+		$source = sanitize_text_field($request->get_param('source') ?? 'icons');
+		$icons = IconsManager::for($source);
 
 		switch ($action) {
 			case 'refresh-icons':
+				// Force regenerate CSS immediately
 				$icons->forceRefresh();
+				IconsManager::regenerateAllCSS([$source => true]);
+
 				return new \WP_REST_Response([
 					'success' => true,
 					'message' => "Icon CSS regenerated successfully for '{$source}'"
 				]);
 
+			case 'refresh-all-icons':
+				// Regenerate all icon sources
+				foreach (['icons', 'forms', 'dash'] as $src) {
+					IconsManager::for($src)->forceRefresh();
+				}
+				IconsManager::regenerateAllCSS();
+
+				return new \WP_REST_Response([
+					'success' => true,
+					'message' => 'All icon CSS files regenerated successfully'
+				]);
+
 			case 'restore-icon-version':
 				$timestamp = (int)$request->get_param('timestamp');
 				if (empty($timestamp)) {
@@ -186,6 +201,9 @@
 				}
 
 				if ($icons->mergeVersions($timestamps)) {
+					// Regenerate CSS after merge
+					IconsManager::regenerateAllCSS([$source => true]);
+
 					return new \WP_REST_Response([
 						'success' => true,
 						'message' => 'Icon versions merged successfully'
@@ -659,20 +677,22 @@
 
 	public function renderCachePage():void
 	{
-		$connections = CacheManager::getAllConnections();
+		$groups = Cache::getAllGroups();
 
 		// Separate generic vs. specific caches
 		$generic_groups = [];
 		$content_specific = [];
 		$nonce = wp_create_nonce('wp_rest');
 
-		foreach ($connections as $group => $configs) {
-			$is_generic = !$this->isBoundToContentOrTaxonomy($group);
+		// Separate by type
+		$generic = [];
+		$specific = [];
 
-			if ($is_generic) {
-				$generic_groups[$group] = $configs;
+		foreach ($groups as $group => $data) {
+			if ($this->isBoundToContentOrTaxonomy($group)) {
+				$specific[$group] = $data;
 			} else {
-				$content_specific[$group] = $configs;
+				$generic[$group] = $data;
 			}
 		}
 
@@ -728,7 +748,7 @@
 					</tr>
 					</thead>
 					<tbody>
-					<?php foreach ($content_specific as $group => $configs): ?>
+					<?php foreach ($specific as $group => $configs): ?>
 						<tr>
 							<td><strong><?= esc_html($group); ?></strong></td>
 							<td><?= $this->formatConnections($configs); ?></td>
@@ -739,6 +759,19 @@
 							</td>
 						</tr>
 					<?php endforeach; ?>
+					<?php foreach ($generic as $group => $data): ?>
+						<tr>
+							<td><strong><?= esc_html($group); ?></strong></td>
+							<td><?= $this->formatConnections($data); ?></td>
+							<td>
+								<button type="button" class="button"
+										data-action="flush-cache"
+										data-group="<?= esc_attr($group); ?>">
+									<?= jvbDashIcon('trash'); ?> Flush
+								</button>
+							</td>
+						</tr>
+					<?php endforeach; ?>
 					</tbody>
 				</table>
 			</details>
@@ -820,15 +853,24 @@
 		return false;
 	}
 
-	protected function formatConnections(array $configs): string
+	protected function formatConnections(array $data): string
 	{
-		$connections = [];
-		foreach ($configs as $config) {
-			$parent = $config['parent'] ?? 'unknown';
-			$scope = $config['scope'] ?? 'id';
-			$connections[] = "{$parent} ({$scope})";
+		$parts = [];
+
+		if (!empty($data['connects_to'])) {
+			$targets = array_map(function($conn) {
+				$flush_text = $conn['flush'] ? ' (flush all)' : '';
+				return $conn['group'] . $flush_text;
+			}, $data['connects_to']);
+			$parts[] = '<strong>Invalidates:</strong> ' . implode(', ', $targets);
 		}
-		return esc_html(implode(', ', $connections));
+
+		if (!empty($data['connected_from'])) {
+			$sources = array_map(fn($conn) => $conn['group'], $data['connected_from']);
+			$parts[] = '<strong>Invalidated by:</strong> ' . implode(', ', $sources);
+		}
+
+		return $parts ? implode('<br>', $parts) : 'No connections';
 	}
 
 	public function handleCacheActions($response, $request, $action):WP_REST_Response
@@ -854,7 +896,8 @@
 				], 400);
 			}
 
-			\JVBase\managers\CacheManager::invalidateAll($group);
+			$group = sanitize_text_field($request->get_param('group'));
+			Cache::invalidateGroup($group);
 
 			return new WP_REST_Response([
 				'success' => true,
@@ -862,68 +905,6 @@
 			]);
 		}
 
-		if ($action === 'merge-icon-versions') {
-			$timestamps = $request->get_param('timestamps');
-
-			if (empty($timestamps) || !is_array($timestamps)) {
-				return new WP_REST_Response([
-					'success' => false,
-					'message' => 'No versions selected for merging'
-				], 400);
-			}
-
-			// Convert to integers
-			$timestamps = array_map('intval', $timestamps);
-
-			if (count($timestamps) < 2) {
-				return new WP_REST_Response([
-					'success' => false,
-					'message' => 'Please select at least 2 versions to merge'
-				], 400);
-			}
-
-			$icons = \JVBase\managers\IconsManager::getInstance();
-
-			if ($icons->mergeVersions($timestamps)) {
-				return new WP_REST_Response([
-					'success' => true,
-					'message' => 'Icon versions merged successfully'
-				]);
-			}
-
-			return new WP_REST_Response([
-				'success' => false,
-				'message' => 'Failed to merge icon versions'
-			], 500);
-		}
-
-		if ($action === 'refresh-icons') {
-			$icons = \JVBase\managers\IconsManager::getInstance();
-			$icons->forceRefresh();
-
-			return new WP_REST_Response([
-				'success' => true,
-				'message' => 'Icon CSS refresh triggered'
-			]);
-		}
-
-		if ($action === 'restore-icon-version') {
-			$timestamp = (int)$request->get_param('timestamp');
-			$icons = \JVBase\managers\IconsManager::getInstance();
-
-			if ($icons->restoreVersion($timestamp)) {
-				return new WP_REST_Response([
-					'success' => true,
-					'message' => 'Icon version restored successfully'
-				]);
-			}
-
-			return new WP_REST_Response([
-				'success' => false,
-				'message' => 'Failed to restore icon version'
-			], 500);
-		}
-
 		return $response;
 	}
 
@@ -936,7 +917,7 @@
 		// Get all registered icon sources
 		$all_sources = ['icons', 'forms', 'dash']; // You could get this dynamically if needed
 
-		$icons = \JVBase\managers\IconsManager::for($current_source);
+		$icons = IconsManager::for($current_source);
 		$versions = $icons->getVersionHistory();
 		$nonce = wp_create_nonce('wp_rest');
 
diff --git a/inc/managers/AjaxRateLimiter.php b/inc/managers/AjaxRateLimiter.php
deleted file mode 100644
index 74272af..0000000
--- a/inc/managers/AjaxRateLimiter.php
+++ /dev/null
@@ -1,325 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-if (!defined('ABSPATH')) {
-	exit;
-}
-
-/**
- * Simple rate limiter for AJAX requests (non-REST)
- * Includes both hourly limits AND burst protection
- */
-class AjaxRateLimiter
-{
-	protected array $limits = [
-		'login' => [
-			'count' => 20,           // Hourly limit
-			'window' => 3600,        // 1 hour
-			'burst_count' => 5,      // Burst limit
-			'burst_window' => 60     // 1 minute
-		],
-		'register' => [
-			'count' => 10,
-			'window' => 3600,
-			'burst_count' => 3,
-			'burst_window' => 60
-		],
-		'lostpassword' => [
-			'count' => 10,
-			'window' => 3600,
-			'burst_count' => 3,
-			'burst_window' => 60
-		],
-		'resetpass' => [
-			'count' => 10,
-			'window' => 3600,
-			'burst_count' => 3,
-			'burst_window' => 60
-		],
-	];
-
-	/**
-	 * Check if action is within rate limits (both hourly and burst)
-	 *
-	 * @param string $action The action being performed (login, register, etc.)
-	 * @return bool True if within limits, false if exceeded
-	 */
-	public function checkLimit(string $action): bool
-	{
-		// Check burst protection first (stricter, prevents rapid-fire)
-		if (!$this->checkBurstLimit($action)) {
-			return false;
-		}
-
-		// Then check hourly limit
-		return $this->checkHourlyLimit($action);
-	}
-
-	/**
-	 * Check burst protection (prevents rapid-fire attempts)
-	 *
-	 * Example: 5 login attempts in 10 seconds = blocked
-	 *
-	 * @param string $action The action being performed
-	 * @return bool True if within burst limits, false if exceeded
-	 */
-	protected function checkBurstLimit(string $action): bool
-	{
-		$limit = $this->getLimit($action);
-
-		// Skip if no burst protection configured
-		if (!isset($limit['burst_count'])) {
-			return true;
-		}
-
-		$key = $this->getCacheKey($action) . '_burst';
-		$data = get_transient($key);
-
-		if (!$data) {
-			$data = ['count' => 0, 'first_attempt' => time()];
-		}
-
-		// Check if burst window expired
-		$elapsed = time() - $data['first_attempt'];
-		if ($elapsed >= $limit['burst_window']) {
-			// Window expired, reset
-			$data = ['count' => 0, 'first_attempt' => time()];
-		}
-
-		// Check if burst limit exceeded
-		if ($data['count'] >= $limit['burst_count']) {
-			// Log for security monitoring
-			error_log(sprintf(
-				'Burst rate limit exceeded for %s from %s: %d attempts in %d seconds',
-				$action,
-				$this->getClientIp(),
-				$data['count'],
-				$elapsed
-			));
-			return false;
-		}
-
-		// Increment and save
-		$data['count']++;
-		set_transient($key, $data, $limit['burst_window']);
-
-		return true;
-	}
-
-	/**
-	 * Check hourly rate limit
-	 *
-	 * @param string $action The action being performed
-	 * @return bool True if within hourly limits, false if exceeded
-	 */
-	protected function checkHourlyLimit(string $action): bool
-	{
-		$key = $this->getCacheKey($action);
-		$limit = $this->getLimit($action);
-
-		// Get current count
-		$data = get_transient($key);
-		if (!$data) {
-			$data = ['count' => 0, 'first_attempt' => time()];
-		}
-
-		// Check if window has expired
-		if (time() - $data['first_attempt'] >= $limit['window']) {
-			// Window expired, reset
-			$data = ['count' => 0, 'first_attempt' => time()];
-		}
-
-		// Check if limit exceeded
-		if ($data['count'] >= $limit['count']) {
-			// Log for security monitoring
-			error_log(sprintf(
-				'Hourly rate limit exceeded for %s from %s: %d attempts',
-				$action,
-				$this->getClientIp(),
-				$data['count']
-			));
-			return false;
-		}
-
-		// Increment and save
-		$data['count']++;
-		set_transient($key, $data, $limit['window']);
-
-		return true;
-	}
-
-	/**
-	 * Get remaining attempts for an action
-	 *
-	 * @param string $action The action being performed
-	 * @return array ['remaining' => int, 'reset_at' => int, 'burst_remaining' => int, 'burst_reset_at' => int]
-	 */
-	public function getRemaining(string $action): array
-	{
-		$limit = $this->getLimit($action);
-
-		// Hourly remaining
-		$key = $this->getCacheKey($action);
-		$data = get_transient($key);
-
-		$hourly_remaining = $limit['count'];
-		$hourly_reset_at = time() + $limit['window'];
-
-		if ($data) {
-			$hourly_remaining = max(0, $limit['count'] - $data['count']);
-			$hourly_reset_at = $data['first_attempt'] + $limit['window'];
-		}
-
-		// Burst remaining (if configured)
-		$burst_remaining = $limit['burst_count'] ?? null;
-		$burst_reset_at = null;
-
-		if (isset($limit['burst_count'])) {
-			$burst_key = $key . '_burst';
-			$burst_data = get_transient($burst_key);
-
-			if ($burst_data) {
-				$burst_remaining = max(0, $limit['burst_count'] - $burst_data['count']);
-				$burst_reset_at = $burst_data['first_attempt'] + $limit['burst_window'];
-			} else {
-				$burst_reset_at = time() + $limit['burst_window'];
-			}
-		}
-
-		return [
-			'remaining' => $hourly_remaining,
-			'reset_at' => $hourly_reset_at,
-			'burst_remaining' => $burst_remaining,
-			'burst_reset_at' => $burst_reset_at
-		];
-	}
-
-	/**
-	 * Generate cache key based on IP and action
-	 *
-	 * @param string $action The action being performed
-	 * @return string Cache key
-	 */
-	protected function getCacheKey(string $action): string
-	{
-		$ip = $this->getClientIp();
-		$user_id = get_current_user_id(); // 0 if not logged in
-
-		return BASE . 'ajax_rate_limit_' . md5($ip . '_' . $user_id . '_' . $action);
-	}
-
-	/**
-	 * Get client IP address (supports proxies)
-	 *
-	 * @return string IP address
-	 */
-	protected function getClientIp(): string
-	{
-		// Check for proxy headers first
-		if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
-			$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
-			// X-Forwarded-For can contain multiple IPs, get the first one
-			$ips = explode(',', $ip);
-			return trim($ips[0]);
-		}
-
-		if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
-			return $_SERVER['HTTP_CLIENT_IP'];
-		}
-
-		return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
-	}
-
-	/**
-	 * Get limit configuration for an action
-	 *
-	 * @param string $action The action being performed
-	 * @return array Limit configuration
-	 */
-	protected function getLimit(string $action): array
-	{
-		return $this->limits[$action] ?? $this->limits['login'];
-	}
-
-	/**
-	 * Clear rate limit for a specific action (useful for testing)
-	 *
-	 * @param string $action The action to clear
-	 * @return bool True if cleared, false otherwise
-	 */
-	public function clearLimit(string $action): bool
-	{
-		$key = $this->getCacheKey($action);
-		$burst_key = $key . '_burst';
-
-		$result1 = delete_transient($key);
-		$result2 = delete_transient($burst_key);
-
-		return $result1 || $result2;
-	}
-
-	/**
-	 * Update limit configuration
-	 *
-	 * @param string $action The action to update
-	 * @param int $count Max attempts per window
-	 * @param int $window Time window in seconds
-	 * @param int|null $burst_count Optional burst limit
-	 * @param int|null $burst_window Optional burst window
-	 */
-	public function setLimit(
-		string $action,
-		int $count,
-		int $window,
-		?int $burst_count = null,
-		?int $burst_window = null
-	): void {
-		$this->limits[$action] = [
-			'count' => $count,
-			'window' => $window
-		];
-
-		if ($burst_count !== null && $burst_window !== null) {
-			$this->limits[$action]['burst_count'] = $burst_count;
-			$this->limits[$action]['burst_window'] = $burst_window;
-		}
-	}
-
-	/**
-	 * Check if IP is currently rate limited
-	 *
-	 * @param string $action The action to check
-	 * @return bool True if rate limited, false otherwise
-	 */
-	public function isRateLimited(string $action): bool
-	{
-		// Check both burst and hourly without incrementing
-		$limit = $this->getLimit($action);
-
-		// Check burst
-		if (isset($limit['burst_count'])) {
-			$burst_key = $this->getCacheKey($action) . '_burst';
-			$burst_data = get_transient($burst_key);
-
-			if ($burst_data) {
-				$elapsed = time() - $burst_data['first_attempt'];
-				if ($elapsed < $limit['burst_window'] && $burst_data['count'] >= $limit['burst_count']) {
-					return true;
-				}
-			}
-		}
-
-		// Check hourly
-		$key = $this->getCacheKey($action);
-		$data = get_transient($key);
-
-		if ($data) {
-			$elapsed = time() - $data['first_attempt'];
-			if ($elapsed < $limit['window'] && $data['count'] >= $limit['count']) {
-				return true;
-			}
-		}
-
-		return false;
-	}
-}
diff --git a/inc/managers/CRUDManager.php b/inc/managers/CRUDManager.php
index 2b62d88..6635406 100644
--- a/inc/managers/CRUDManager.php
+++ b/inc/managers/CRUDManager.php
@@ -14,7 +14,7 @@
  */
 class CRUD {
 	protected CRUDSkeleton $skeleton;
-	protected CacheManager $cache;
+	protected Cache $cache;
 	protected array $config;
 	protected string $content;
 	protected array $taxonomies = [];
@@ -39,10 +39,10 @@
 		$this->user_id = get_current_user_id();
 		$this->config = $this->constant[$content];
 		$this->content = $content;
-		$this->cache = CacheManager::for($content);
+		$this->cache = Cache::for('crud')->connect('post')->connect('taxonomy');
 
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 
 		// Create and configure skeleton
diff --git a/inc/managers/Cache.php b/inc/managers/Cache.php
new file mode 100644
index 0000000..e7ebc94
--- /dev/null
+++ b/inc/managers/Cache.php
@@ -0,0 +1,652 @@
+<?php
+namespace JVBase\managers;
+
+if (!defined('ABSPATH')) {
+	exit;
+}
+
+class Cache
+{
+	private string $group;
+	private int $ttl;
+	private static array $timestamps = [];
+	private const TS_GROUP = 'jvb_http_ts';
+
+	private const CONNECTIONS_OPTION = 'jvb_cache_connections';
+	private static ?array $connections = null;
+	protected array $tags = [];
+
+	private static array $instances = [];
+	private bool $hasRedis;
+
+	private function __construct(string $group, int $ttl)
+	{
+		$this->group = $group;
+		$this->ttl   = $ttl;
+		$this->hasRedis = (bool) wp_using_ext_object_cache();
+	}
+
+	public static function registerHooks(): void
+	{
+		// Post updates (all post types including core)
+		add_action('save_post', [self::class, 'onPostChange'], 10, 2);
+		add_action('delete_post', [self::class, 'onPostDelete']);
+
+		// Post meta updates
+		add_action('updated_post_meta', [self::class, 'onPostMetaChange'], 10, 2);
+		add_action('added_post_meta', [self::class, 'onPostMetaChange'], 10, 2);
+		add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 2);
+
+		// Term updates (all taxonomies)
+		add_action('edited_term', [self::class, 'onTermChange'], 10, 3);
+		add_action('create_term', [self::class, 'onTermChange'], 10, 3);
+		add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
+
+		// Term meta updates
+		add_action('updated_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
+		add_action('added_term_meta', [self::class, 'onTermMetaChange'], 10, 2);
+		add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 2);
+
+		// User updates
+		add_action('profile_update', [self::class, 'onUserChange'], 10, 2);
+		add_action('user_register', [self::class, 'onUserChange'], 10, 1);
+		add_action('deleted_user', [self::class, 'onUserDelete']);
+
+		// User meta updates
+		add_action('updated_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
+		add_action('added_user_meta', [self::class, 'onUserMetaChange'], 10, 2);
+		add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 2);
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Factory
+	 * ------------------------------------------------------------------- */
+
+	public static function for(string $group, int $ttl = HOUR_IN_SECONDS): self
+	{
+		$group = sanitize_key($group);
+
+		if (!isset(self::$instances[$group])) {
+			self::$instances[$group] = new self($group, $ttl);
+		}
+
+		return self::$instances[$group];
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Core operations
+	 * ------------------------------------------------------------------- */
+
+	public function remember(int|string|array $key, callable $callback, ?int $ttl = null): mixed
+	{
+		if (is_array($key)) {
+			$key = $this->generateKey($key);
+		}
+		if (!empty($this->tags)) {
+			return $this->rememberTagged(
+				$key,
+				$this->tags,
+				$callback,
+				$ttl
+			);
+		}
+
+		$value = $this->get($key);
+
+		if ($value !== false) {
+			return $value;
+		}
+
+		$value = $callback();
+
+		if ($value !== null && $value !== false) {
+			$this->set($key, $value);
+		}
+
+		return $value;
+	}
+
+	public function get(int|string|array $id): mixed
+	{
+		if (is_array($id)) {
+			$id = $this->generateKey($id);
+		}
+		if ($this->hasRedis) {
+			$value = wp_cache_get($id, $this->group);
+		} else {
+			$value = get_transient("jvb_{$this->group}_{$id}");
+		}
+
+		return $value;
+	}
+
+	public function set(int|string|array $id, mixed $value, ?int $ttl = null): void
+	{
+		if (is_array($id)) {
+			$id = $this->generateKey($id);
+		}
+		$ttl = $ttl ?? $this->ttl;
+		if ($this->hasRedis) {
+			wp_cache_set($id, $value, $this->group, $ttl);
+		} else {
+			set_transient("jvb_{$this->group}_{$id}", $value, $ttl);
+		}
+	}
+
+	public function forget(int|string|array $id): void
+	{
+		if (is_array($id)) {
+			$id = $this->generateKey($id);
+		}
+		if ($this->hasRedis) {
+			wp_cache_delete($id, $this->group);
+		} else {
+			delete_transient("jvb_{$this->group}_{$id}");
+		}
+	}
+
+	public function flush(): void
+	{
+		if ($this->hasRedis) {
+			if (function_exists('wp_cache_flush_group')) {
+				wp_cache_flush_group($this->group);
+			} else {
+				wp_cache_flush();
+			}
+		} else {
+			$this->clearGroupTransients();
+		}
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Invalidation
+	 * ------------------------------------------------------------------- */
+
+	public static function invalidateItem(string $group, int|string|array $id): void
+	{
+		if (is_array($id)) {
+			$id = self::for($group)->generateKey($id);
+		}
+		$group = sanitize_key($group);
+
+		if (wp_using_ext_object_cache()) {
+			wp_cache_delete($id, $group);
+		} else {
+			delete_transient("jvb_{$group}_{$id}");
+		}
+		self::touch($group);
+
+		foreach (self::connections()[$group] ?? [] as $conn) {
+			$target = $conn['target'] ?? $conn; // Backwards compat if still string
+			$flush = $conn['flush'] ?? false;
+
+			if ($flush) {
+				// Flush entire target group
+				self::invalidateGroup($target);
+			} else {
+				// Just delete this item ID
+				if (wp_using_ext_object_cache()) {
+					wp_cache_delete($id, $target);
+				} else {
+					delete_transient("jvb_{$target}_{$id}");
+				}
+				self::touch($target);
+			}
+		}
+		self::invalidateByTag($group, $id);
+	}
+
+	public static function invalidateGroup(string $group): void
+	{
+		$group = sanitize_key($group);
+
+		if (wp_using_ext_object_cache()) {
+			wp_cache_flush_group($group);
+		} else {
+			$instance = self::for($group);
+			$instance->clearGroupTransients();
+		}
+
+		self::touch($group);
+
+		foreach (self::connections()[$group] ?? [] as $conn) {
+			$target = $conn['target'] ?? $conn; // Backwards compat
+
+			// When flushing entire source group, always flush connected targets
+			// (regardless of flush flag - we don't know which items to delete)
+			if (wp_using_ext_object_cache()) {
+				wp_cache_flush_group($target);
+			} else {
+				$instance = self::for($target);
+				$instance->clearGroupTransients();
+			}
+			self::touch($target);
+		}
+	}
+
+	public static function touch(string $group): int
+	{
+		$group = sanitize_key($group);
+		$time  = time();
+
+		if (wp_using_ext_object_cache()) {
+			wp_cache_set($group, $time, self::TS_GROUP, WEEK_IN_SECONDS);
+		} else {
+			set_transient('jvb_ts_' . $group, $time, WEEK_IN_SECONDS);
+		}
+
+		self::$timestamps[$group] = $time;
+		return $time;
+	}
+
+	public static function lastModified(string|array $groups): int
+	{
+		if (is_array($groups)) {
+			return max(array_map([self::class, 'lastModified'], $groups));
+		}
+
+		$group = sanitize_key($groups);
+
+		if (isset(self::$timestamps[$group])) {
+			return self::$timestamps[$group];
+		}
+
+		if (wp_using_ext_object_cache()) {
+			$ts = (int) wp_cache_get($group, self::TS_GROUP);
+		} else {
+			$ts = (int) get_transient('jvb_ts_' . $group);
+		}
+
+		if (!$ts) {
+			$ts = time();
+			if (wp_using_ext_object_cache()) {
+				wp_cache_set($group, $ts, self::TS_GROUP, WEEK_IN_SECONDS);
+			} else {
+				set_transient('jvb_ts_' . $group, $ts, WEEK_IN_SECONDS);
+			}
+		}
+
+		return self::$timestamps[$group] = $ts;
+	}
+
+	public function getLastModifiedForTags(array $tags): ?int
+	{
+		if (!$this->hasRedis) {
+			return null;
+		}
+		$redis = self::redis();
+		if (!$redis) {
+			return null;
+		}
+
+		$lastModified = 0;
+
+		foreach ($tags as $tag) {
+			$ts = $redis->get("jvb:tag:{$tag}:lastModified");
+			if ($ts) {
+				$lastModified = max($lastModified, (int) $ts);
+			}
+		}
+
+		return $lastModified ?: null;
+	}
+
+	/****************************************************
+	 * CONNECTIONS
+	 ****************************************************/
+	private static function connections(): array
+	{
+		if (self::$connections === null) {
+			self::$connections = get_option(self::CONNECTIONS_OPTION, []);
+		}
+		return self::$connections;
+	}
+
+
+	public function connect(string $source, bool $flush = false): self
+	{
+		$source = sanitize_key($source);
+		$target = $this->group;
+
+		$all = self::connections();
+		$all[$source] ??= [];
+
+		$before = count($all[$source]);
+
+		// Add the connection
+		$all[$source][] = ['target' => $target, 'flush' => $flush];
+
+		// Remove duplicates by serializing for comparison
+		$all[$source] = array_values(array_unique($all[$source], SORT_REGULAR));
+
+		// Only update if something actually changed
+		if (count($all[$source]) !== $before) {
+			update_option(self::CONNECTIONS_OPTION, $all, false);
+			self::$connections = $all;
+		}
+
+		return $this;
+	}
+	/****************************************************
+	 * REDIS
+	 ****************************************************/
+	private static function redis(): ?\Redis
+	{
+		global $wp_object_cache;
+
+		return $wp_object_cache instanceof \WP_Object_Cache
+		&& isset($wp_object_cache->redis)
+			? $wp_object_cache->redis
+			: null;
+	}
+
+	/**
+	 * Remember with tags for complex invalidation scenarios
+	 *
+	 * Example: Cache user favorites tagged by each post ID
+	 * When any post updates, this cache entry auto-invalidates
+	 *
+	 * @param int|string|array $key Cache key
+	 * @param array $tags Array of [group, id] pairs: [['post', 123], ['user', 456]]
+	 * @param callable $callback Function to generate value if cache miss
+	 * @return mixed Cached or generated value
+	 */
+	public function rememberTagged(
+		int|string|array $key,
+		array $tags,
+		callable $callback,
+		?int $ttl = null
+	): mixed {
+		if (is_array($key)) {
+			$id = $this->generateKey($key);
+		}
+		$tags = array_unique(array_merge(
+			$this->getTags(),
+			array_map('sanitize_key', $tags)
+		));
+
+
+		$value = wp_cache_get($key, $this->group);
+		if ($value !== false) {
+			return $value;
+		}
+
+		$value = $callback();
+		if ($value === null || $value === false) {
+			return $value;
+		}
+
+		wp_cache_set($key, $value, $this->group, $this->ttl);
+
+		if ($redis = self::redis()) {
+			foreach ($tags as [$tagGroup, $tagId]) {
+				$redis->sAdd("tag:$tagGroup:$tagId", "{$this->group}:$key");
+			}
+		}
+
+		return $value;
+	}
+
+	private static function invalidateByTag(string $group, int|string|array $id): void
+	{
+		if (is_array($id)) {
+			$id = self::for($group)->generateKey($id);
+		}
+		if (!$redis = self::redis()) {
+			return;
+		}
+
+		$key = "tag:$group:$id";
+		$targets = $redis->sMembers($key);
+
+		foreach ($targets as $target) {
+			[$group, $id] = explode(':', $target, 2);
+			wp_cache_delete($id, $group);
+		}
+
+		$redis->del($key);
+	}
+
+	public function tag(string $tag): static
+	{
+		$this->tags[] = sanitize_key($tag);
+		return $this;
+	}
+
+	public function getTags(): array
+	{
+		return array_unique($this->tags);
+	}
+	/****************************************************
+	 * TRANSIENT HELPER
+	 ****************************************************/
+	private function clearGroupTransients(): void
+	{
+		global $wpdb;
+
+		$pattern = '_transient_jvb_' . $this->group . '_%';
+		$timeout_pattern = '_transient_timeout_jvb_' . $this->group . '_%';
+
+		// Remove LIMIT to avoid table locks, add retry for deadlocks
+		$attempts = 0;
+		$max_attempts = 3;
+
+		while ($attempts < $max_attempts) {
+			$result = $wpdb->query(
+				$wpdb->prepare(
+					"DELETE FROM $wpdb->options
+                 WHERE option_name LIKE %s OR option_name LIKE %s",
+					$pattern,
+					$timeout_pattern
+				)
+			);
+
+			// Success or non-deadlock error
+			if ($result !== false || !str_contains($wpdb->last_error, 'Deadlock')) {
+				break;
+			}
+
+			$attempts++;
+			if ($attempts < $max_attempts) {
+				usleep(50000); // Wait 50ms before retry
+			}
+		}
+	}
+	/****************************************************
+	 * HOOKS
+	 ****************************************************/
+	/****************************************************
+	 * HOOKS - Posts
+	 ****************************************************/
+	public static function onPostChange(int $postId, \WP_Post $post): void
+	{
+		if (wp_is_post_revision($postId) || wp_is_post_autosave($postId)) {
+			return;
+		}
+
+//		error_log('[Clearing cache for post change: '.$postId.']');
+		self::invalidateItem('post', $postId);
+	}
+
+	public static function onPostDelete(int $postId): void
+	{
+//		error_log('[Clearing cache for post delete: '.$postId.']');
+		self::invalidateItem('post', $postId);
+	}
+
+	public static function onPostMetaChange(int $metaId, int $objectId): void
+	{
+//		error_log('[Clearing cache for post meta change: '.$objectId.']');
+		self::invalidateItem('post', $objectId);
+	}
+
+	public static function onPostMetaDelete(array $metaIds, int $objectId): void
+	{
+//		error_log('[Clearing cache for post meta delete: '.$objectId.']');
+		self::invalidateItem('post', $objectId);
+	}
+
+	/****************************************************
+	 * HOOKS - Terms
+	 ****************************************************/
+	public static function onTermChange(int $termId, int $ttId, string $taxonomy): void
+	{
+//		error_log('[Clearing cache for term change: '.$termId.']');
+		self::invalidateItem('taxonomy', $termId);
+	}
+
+	public static function onTermDelete(int $termId): void
+	{
+//		error_log('[Clearing cache for term delete: '.$termId.']');
+		self::invalidateItem('taxonomy', $termId);
+	}
+
+	public static function onTermMetaChange(int $metaId, int $objectId): void
+	{
+//		error_log('[Clearing cache for term meta change: '.$objectId.']');
+		self::invalidateItem('taxonomy', $objectId);
+	}
+
+	public static function onTermMetaDelete(array $metaIds, int $objectId): void
+	{
+//		error_log('[Clearing cache for term meta delete: '.$objectId.']');
+		self::invalidateItem('taxonomy', $objectId);
+	}
+
+	/****************************************************
+	 * HOOKS - Users
+	 ****************************************************/
+	public static function onUserChange(int $userId): void
+	{
+//		error_log('[Clearing cache for user change: '.$userId.']');
+		self::invalidateItem('user', $userId);
+	}
+
+	public static function onUserDelete(int $userId): void
+	{
+//		error_log('[Clearing cache for user delete: '.$userId.']');
+		self::invalidateItem('user', $userId);
+	}
+
+	public static function onUserMetaChange(int $metaId, int $objectId): void
+	{
+//		error_log('[Clearing cache for user meta change: '.$objectId.']');
+		self::invalidateItem('user', $objectId);
+	}
+
+	public static function onUserMetaDelete(array $metaIds, int $objectId): void
+	{
+//		error_log('[Clearing cache for user meta delete: '.$objectId.']');
+		self::invalidateItem('user', $objectId);
+	}
+	/***************************************************
+	 * UTILITY
+	 **************************************************/
+	/**
+	 * Generate a cache key from parameters
+	 * Useful for caching based on multiple variables
+	 *
+	 * @param array $params Key-value pairs that uniquely identify this cache entry
+	 * @return string MD5 hash of sorted parameters
+	 */
+	public function generateKey(array $params): string
+	{
+		ksort($params);
+		return md5(serialize($params));
+	}
+
+	/**
+	 * Nuclear option: Flush ALL registered cache groups
+	 * Use for debugging or after major updates
+	 *
+	 * @return int Number of groups flushed
+	 */
+	public static function flushAll(): int
+	{
+		$all = self::connections();
+		$groups = [];
+
+		// Collect all unique groups from connections
+		foreach ($all as $source => $targets) {
+			$groups[$source] = true;
+			foreach ($targets as $conn) {
+				$target = $conn['target'] ?? $conn;
+				$groups[$target] = true;
+			}
+		}
+
+		// Add any instantiated groups not in connections
+		foreach (array_keys(self::$instances) as $group) {
+			$groups[$group] = true;
+		}
+
+		// Flush each group
+		$count = 0;
+		foreach (array_keys($groups) as $group) {
+			self::invalidateGroup($group);
+			$count++;
+		}
+
+		// Also flush timestamp cache
+		if (wp_using_ext_object_cache()) {
+			wp_cache_flush_group(self::TS_GROUP);
+		}
+
+		// Clear in-memory caches
+		self::$timestamps = [];
+
+		return $count;
+	}
+
+	/**
+	 * Get all cache groups and their connections for admin display
+	 *
+	 * @return array Format: ['group' => ['connects_to' => [...], 'connected_from' => [...]]]
+	 */
+	public static function getAllGroups(): array
+	{
+		$connections = self::connections();
+		$groups = [];
+
+		// Build bidirectional view
+		foreach ($connections as $source => $targets) {
+			if (!isset($groups[$source])) {
+				$groups[$source] = ['connects_to' => [], 'connected_from' => []];
+			}
+
+			foreach ($targets as $conn) {
+				$target = $conn['target'] ?? $conn;
+				$flush = $conn['flush'] ?? false;
+
+				// Source connects to target
+				$groups[$source]['connects_to'][] = [
+					'group' => $target,
+					'flush' => $flush
+				];
+
+				// Target is connected from source
+				if (!isset($groups[$target])) {
+					$groups[$target] = ['connects_to' => [], 'connected_from' => []];
+				}
+				$groups[$target]['connected_from'][] = [
+					'group' => $source,
+					'flush' => $flush
+				];
+			}
+		}
+
+		// Add any instantiated groups not in connections
+		foreach (array_keys(self::$instances) as $group) {
+			if (!isset($groups[$group])) {
+				$groups[$group] = ['connects_to' => [], 'connected_from' => []];
+			}
+		}
+
+		return $groups;
+	}
+
+	public function hasRedis():bool
+	{
+		return $this->hasRedis;
+	}
+}
diff --git a/inc/managers/CacheManager.php b/inc/managers/CacheManager.php
deleted file mode 100644
index 2583431..0000000
--- a/inc/managers/CacheManager.php
+++ /dev/null
@@ -1,856 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-if (!defined('ABSPATH')) {
-	exit;
-}
-
-/**
- * Manages HTTP cache timestamps and relationship-based invalidation
- *
- * Data caching: Use wrapper methods or wp_cache_get/set directly
- * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
- */
-class CacheManager
-{
-	private const CONNECTIONS_OPTION = BASE.'cache_connections';
-	private static ?array $connections_cache = null; // Cache in memory
-	private string $prefix = BASE;
-	private string $group;
-	private int $cache_ttl;
-	private static ?bool $use_object_cache = null;
-	private static array $instances = []; // Cache instances per type
-	private static array $http_timestamps = []; // Request-level memory cache
-	private static ?CacheManager $singleton = null;
-
-	/**
-	 * Private constructor - use for() factory method instead
-	 */
-	private function __construct(string $group, ?int $ttl = null)
-	{
-		$this->group = jvbNoBase($group);
-		$this->cache_ttl = $ttl ?: 3600;
-
-		if (is_null(static::$use_object_cache)) {
-			static::$use_object_cache = wp_using_ext_object_cache();
-		}
-
-		add_action('init', [$this, 'registerHooks']);
-	}
-
-	/**
-	 * Get singleton instance (for general cache operations)
-	 * For type-specific operations, use for() or forUser() instead
-	 */
-	public static function getInstance(): self
-	{
-		if (self::$singleton === null) {
-			self::$singleton = new self('global', HOUR_IN_SECONDS);
-		}
-		return self::$singleton;
-	}
-
-	/**
-	 * Get all cache connections (public accessor)
-	 *
-	 * @return array Array of cache group connections
-	 */
-	public static function getAllConnections(): array
-	{
-		return self::getConnections();
-	}
-
-	/**
-	 * Get all registered cache groups
-	 *
-	 * @return array List of cache group names
-	 */
-	public static function getAllGroups(): array
-	{
-		$connections = self::getConnections();
-		return array_keys($connections);
-	}
-	/**
-	 * Register WordPress hooks for automatic cache invalidation
-	 * Call this once during plugin initialization
-	 */
-	public static function registerHooks(): void
-	{
-		// Post updates (all post types including core)
-		add_action('save_post', [self::class, 'onPostSave'], 10, 2);
-		add_action('delete_post', [self::class, 'onPostDelete']);
-		// Meta updates (will catch MetaManager updates)
-		add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
-		add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
-		add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4);
-		// transition_post_status?
-
-		// Term updates (all taxonomies)
-		add_action('edited_term', [self::class, 'onTermSave'], 10, 3);
-		add_action('create_term', [self::class, 'onTermSave'], 10, 3);
-		add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
-
-		// Term meta updates
-		add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
-		add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
-		add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4);
-
-		// User updates
-		add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2);
-		add_action('user_register', [self::class, 'onUserUpdate'], 10, 1);
-		add_action('deleted_user', [self::class, 'onUserDelete']);
-
-		// User meta updates
-		add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
-		add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
-		add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4);
-	}
-
-	/**
-	 * Get or create a cache manager instance for a content type
-	 *
-	 * @param string $type Content type (tattoo, style, etc.)
-	 * @param int|null $ttl Optional TTL override
-	 * @return self Fluent interface
-	 */
-	public static function for(string $type, ?int $ttl = null): self
-	{
-		$type = jvbNoBase($type);
-		$key = $type . ($ttl ? "_{$ttl}" : '');
-
-		if (!isset(self::$instances[$key])) {
-			self::$instances[$key] = new self($type, $ttl);
-		}
-
-		return self::$instances[$key];
-	}
-
-	/**
-	 * Get cache manager for a specific user
-	 * Each user gets their own cache group for complete isolation
-	 *
-	 * @param int $user_id User ID
-	 * @param int|null $ttl Optional TTL
-	 * @return self
-	 */
-	public static function forUser(int $user_id, ?int $ttl = null): self
-	{
-		return self::for("user_{$user_id}", $ttl);
-	}
-
-	/**
-	 * Get HTTP cache timestamp for content type(s)
-	 * Used for ETag and Last-Modified header generation
-	 *
-	 * @param string|array $types Single type or array of types
-	 * @return int Latest timestamp (Unix time)
-	 */
-	public static function getTimestamp(string|array $types): int
-	{
-		// Multiple types - return latest
-		if (is_array($types)) {
-			$latest = 0;
-			foreach ($types as $type) {
-				$timestamp = self::getTimestamp($type);
-				if ($timestamp > $latest) {
-					$latest = $timestamp;
-				}
-			}
-			return $latest ?: time();
-		}
-
-		$type = jvbNoBase($types);
-
-		// Check request-level cache
-		if (isset(self::$http_timestamps[$type])) {
-			return self::$http_timestamps[$type];
-		}
-
-		// Load from cache (Redis or transient - wp_cache handles it)
-		$timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
-
-		// Cache in memory for this request
-		self::$http_timestamps[$type] = $timestamp;
-
-		return $timestamp;
-	}
-
-	/**
-	 * Update HTTP cache timestamp (marks content as modified)
-	 *
-	 * @param string $type Content type
-	 * @return int The new timestamp
-	 */
-	public static function updateTimestamp(string $type): int
-	{
-		$type = jvbNoBase($type);
-		$timestamp = time();
-
-		// Store (Redis or transient - wp_cache handles it)
-		wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
-
-		// Update request cache
-		self::$http_timestamps[$type] = $timestamp;
-
-		do_action('jvb_http_timestamp_updated', $type, $timestamp);
-
-		return $timestamp;
-	}
-
-	/**
-	 * Invalidate cache for a content type
-	 *
-	 * @param string $type Content type to invalidate
-	 * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
-	 * @param bool $flush_connections Whether to flush connected caches
-	 * @return void
-	 */
-	public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void
-	{
-		$type = jvbNoBase($type);
-
-		// Update HTTP timestamp
-		self::updateTimestamp($type);
-
-		// If specific keys provided, only delete those
-		if ($specific_keys !== null) {
-			$instance = self::for($type);
-			if (is_array($specific_keys)) {
-				foreach ($specific_keys as $key) {
-					$instance->delete($key);
-				}
-			} else {
-				$instance->delete($specific_keys);
-			}
-		} else {
-			// Flush the entire group
-			if (function_exists('wp_cache_flush_group')) {
-				wp_cache_flush_group($type);
-			} else {
-				wp_cache_flush();
-			}
-		}
-
-		// Flush connected caches
-		if ($flush_connections) {
-			self::for($type)->connections();
-		}
-
-		do_action('jvb_cache_invalidated', $type);
-	}
-
-	/**
-	 * Invalidate only specific keys for a type (doesn't flush group or update timestamp)
-	 * Use this when you want surgical cache invalidation
-	 *
-	 * @param string $type Content type
-	 * @param string|array $keys Key(s) to delete
-	 * @return void
-	 */
-	public static function invalidateKeys(string $type, string|array $keys): void
-	{
-		$instance = self::for($type);
-
-		if (is_array($keys)) {
-			foreach ($keys as $key) {
-				$instance->delete($key);
-			}
-		} else {
-			$instance->delete($keys);
-		}
-	}
-
-	/**
-	 * Fluent instance method to invalidate this cache type
-	 *
-	 * @param string|array|null $specific_keys Optional specific key(s)
-	 * @param bool $flush_connections Whether to flush connected caches
-	 * @return self For chaining
-	 */
-	public function invalidate($specific_keys = null, bool $flush_connections = true): self
-	{
-		self::invalidateAll($this->group, $specific_keys, $flush_connections);
-		return $this;
-	}
-
-	/**
-	 * Get the HTTP timestamp for this instance's type
-	 *
-	 * @return int
-	 */
-	public function timestamp(): int
-	{
-		return self::getTimestamp($this->group);
-	}
-
-	/**
-	 * Update the HTTP timestamp for this instance's type
-	 *
-	 * @return self For chaining
-	 */
-	public function touch(): self
-	{
-		self::updateTimestamp($this->group);
-		return $this;
-	}
-
-	/**
-	 * Get a value from the cache
-	 * @param string|array $key The key to look up (auto-generates key from array of key=>values)
-	 * @param string|null $group The group to get from. Defaults to current group
-	 * @return mixed
-	 */
-	public function get(string|array $key, ?string $group = null): mixed
-	{
-		$group = $group ?: $this->group;
-		$key = $this->normalizeKey($key);
-		$cache_key = $this->buildKey($key);
-
-		$value = wp_cache_get($cache_key, $group);
-
-		// Fallback to transient if no external object cache
-		if ($value === false && !wp_using_ext_object_cache()) {
-			$value = get_transient($group . '_' . $cache_key);
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Store a value in cache
-	 * @param string|array $key The key to look up (auto-generates key from array of key=>values)
-	 * @param mixed $value The Value to set
-	 * @param int|null $ttl The ttl (defaults to current set ttl)
-	 * @param string|null $group The group to add cache to (defaults to current group))
-	 * @return bool
-	 */
-	public function set(string|array $key, mixed $value, ?int $ttl = null, ?string $group = null): bool
-	{
-		$ttl = $ttl ?: $this->cache_ttl;
-		$group = $group ?: $this->group;
-		$key = $this->normalizeKey($key);
-		$cache_key = $this->buildKey($key);
-
-		self::updateTimestamp($this->group);
-
-		// Try object cache first
-		$result = wp_cache_set($cache_key, $value, $group, $ttl);
-
-		// If no external object cache, also store in transient for persistence
-		if (!wp_using_ext_object_cache()) {
-			set_transient($group . '_' . $cache_key, $value, $ttl);
-		}
-
-		return $result;
-	}
-	/**
-	 * Delete a cached value
-	 * @param string|array $key The key to look up (auto-generates key from array of key=>values)
-	 * @param string|null $group The group to delete from (defaults to current group)
-	 * @return bool
-	 */
-	public function delete(string|array $key, ?string $group = null): bool
-	{
-		$group = $group ?: $this->group;
-		$key = $this->normalizeKey($key);
-		$cache_key = $this->buildKey($key);
-
-		$result = wp_cache_delete($cache_key, $group);
-
-		// Also delete transient if no external object cache
-		if (!wp_using_ext_object_cache()) {
-			delete_transient($group . '_' . $cache_key);
-		}
-
-		return $result;
-	}
-
-
-	/**
-	 * Clear all cache for this group
-	 * @return bool
-	 */
-	public function clear(): bool
-	{
-		try {
-			if (function_exists('wp_cache_flush_group')) {
-				wp_cache_flush_group($this->group);
-			}
-
-			// Clear transients for this group if no external object cache
-			if (!wp_using_ext_object_cache()) {
-				$this->clearGroupTransients();
-			}
-
-			self::updateTimestamp($this->group);
-			return true;
-		} catch (\Exception $e) {
-			return false;
-		}
-	}
-
-	/**
-	 * Clear all transients for this cache group
-	 */
-	private function clearGroupTransients(): void
-	{
-		global $wpdb;
-
-		$pattern = '_transient_' . $this->group . '_' . $this->prefix . '%';
-		$timeout_pattern = '_transient_timeout_' . $this->group . '_' . $this->prefix . '%';
-
-		$wpdb->query(
-			$wpdb->prepare(
-				"DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s",
-				$pattern,
-				$timeout_pattern
-			)
-		);
-	}
-
-	/**
-	 * Helper to generateKey from array if applicable
-	 * @param string|array $key
-	 * @return string
-	 */
-	private function normalizeKey(string|array $key): string
-	{
-		return is_array($key) ? $this->generateKey($key) : $key;
-	}
-
-	/**
-	 * Generate a cache key from parameters
-	 * @param array $params An array of key/values that differentiates this cache item from others
-	 * @return string
-	 */
-	public function generateKey(array $params): string
-	{
-		// Sort params for consistent key generation
-		ksort($params);
-		return md5(serialize($params));
-	}
-
-	/**
-	 * The workhorse shorthand of CacheManager. Tests the cache, and calls the callback if nothing is found.
-	 * @param string|array $key The key to look up (auto-generates key from array of key=>values)
-	 * @param callable $callback The callback to generate the value for this key
-	 * @param int|null $ttl The time-to-live for the cache. Defaults to constructor
-	 * @param string|null $group The group to save cache to. Defaults to constructor
-	 * @return mixed
-	 */
-	public function remember(string|array $key, callable $callback, ?int $ttl = null, ?string $group = null): mixed
-	{
-		$group = $group ?: $this->group;
-		$ttl = $ttl ?: $this->cache_ttl;
-		$key = $this->normalizeKey($key);
-
-		$value = $this->get($key, $group);
-
-		if ($value === false) {
-			$value = $callback();
-			if ($value !== false && $value !== null) {
-				$this->set($key, $value, $ttl, $group);
-			}
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Build the cache key
-	 * @param string $key
-	 * @return string
-	 */
-	private function buildKey(string $key): string
-	{
-		return $this->prefix . $key;
-	}
-
-	/**
-	 * Get instance group name (for debugging)
-	 */
-	public function getGroup(): string
-	{
-		return $this->group;
-	}
-
-
-	/***************************************************************************
-	 * CONNECTIONS
-	 * Connect to other caches by instantiating and defining connection
-	 * Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern)
-	 * Where: 	$type = content / taxonomy / user
-	 * 			$scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role)
-	 * 			$keyPattern = ??
-	***************************************************************************/
-	/**
-	 * Define a connection between cache groups
-	 * Connected caches will have their ID-based keys deleted when this cache invalidates
-	 *
-	 * @param string $type Grand overview ('post', 'taxonomy', 'user')
-	 * @param string $scope Type-specific constant, user role, or 'id'
-	 * @return self For chaining
-	 */
-	public function connectTo(string $type, string $scope = 'id'): self
-	{
-		//TODO: Handle connect to where $type === 'all'
-		$connections = self::getConnections();
-
-		if (!isset($connections[$this->group])) {
-			$connections[$this->group] = [];
-		}
-
-		$new_connection = [
-			'parent' => $type,
-			'scope' => $scope
-		];
-
-		// Check if already exists
-		foreach ($connections[$this->group] as $existing) {
-			if ($existing === $new_connection) {
-				return $this;
-			}
-		}
-
-		$connections[$this->group][] = $new_connection;
-		update_option(self::CONNECTIONS_OPTION, $connections, false);
-		self::$connections_cache = $connections;
-
-		return $this;
-	}
-
-	/**
-	 * Get all registered connections (cached for performance)
-	 *
-	 * @param bool $refresh Force refresh from database
-	 * @return array
-	 */
-	private static function getConnections(bool $refresh = false): array
-	{
-		if (self::$connections_cache === null || $refresh) {
-			self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []);
-		}
-
-		return self::$connections_cache;
-	}
-
-	/**
-	 * Flush all caches connected to this one
-	 *
-	 * @return self For chaining
-	 */
-	public function connections(): self
-	{
-		$all_connections = self::getConnections();
-
-		foreach ($all_connections as $cache_group => $connections) {
-			foreach ($connections as $conn) {
-				if ($this->matchesConnection($conn)) {
-					$this->flushConnection($cache_group, $conn);
-				}
-			}
-		}
-
-		return $this;
-	}
-
-	/**
-	 * Check if this cache group matches a connection definition
-	 */
-	private function matchesConnection(array $connection): bool
-	{
-		$parent = $connection['parent'] ?? '';
-		$scope = $connection['scope'] ?? 'id';
-
-		// Grand overview match
-		if ($this->group === $parent) {
-			return true;
-		}
-
-		// Type-specific match
-		if ($scope !== 'id') {
-			if ($this->group === jvbNoBase($scope)) {
-				return true;
-			}
-
-			// Check constants
-			if ($parent === 'post' && defined('JVB_CONTENT')) {
-				return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group;
-			}
-
-			if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) {
-				return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group;
-			}
-		}
-
-		// ID-specific match: 'user_123' matches 'user' + 'id'
-		if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) {
-			return true;
-		}
-
-		return false;
-	}
-
-	/**
-	 * Flush a connected cache group
-	 * For ID-specific connections, deletes the specific ID key
-	 * For type/overview connections, flushes entire group
-	 */
-	private function flushConnection(string $cache_group, array $connection): void
-	{
-		$scope = $connection['scope'] ?? 'id';
-
-		// ID-specific: delete specific key
-		if ($scope === 'id') {
-			$id = $this->extractIdFromGroup();
-
-			if ($id !== null) {
-				self::invalidateKeys($cache_group, $id);
-				return;
-			}
-		}
-
-		// Type/overview: flush entire group
-		self::invalidateAll($cache_group, specific_keys: null, flush_connections: false);
-	}
-
-	/**
-	 * Extract ID from group name like 'user_123' -> '123'
-	 *
-	 * @return string|null
-	 */
-	private function extractIdFromGroup(): ?string
-	{
-		if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) {
-			return $matches[1];
-		}
-
-		return null;
-	}
-
-	/**
-	 * Register multiple connections at once
-	 */
-	public static function registerConnections(array $connections): void
-	{
-		$existing = self::getConnections();
-		$changed = false;
-
-		foreach ($connections as $cache_group => $configs) {
-			if (!isset($existing[$cache_group])) {
-				$existing[$cache_group] = [];
-			}
-
-			foreach ($configs as $config) {
-				$duplicate = false;
-				foreach ($existing[$cache_group] as $existing_config) {
-					if ($existing_config === $config) {
-						$duplicate = true;
-						break;
-					}
-				}
-
-				if (!$duplicate) {
-					$existing[$cache_group][] = $config;
-					$changed = true;
-				}
-			}
-		}
-
-		if ($changed) {
-			update_option(self::CONNECTIONS_OPTION, $existing, false);
-			self::$connections_cache = $existing;
-		}
-	}
-
-	/**
-	 * Handle post save/update
-	 */
-	public static function onPostSave(int $post_id, \WP_Post $post): void
-	{
-		// Skip revisions and autosaves
-		if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
-			return;
-		}
-
-		$post_type = jvbNoBase($post->post_type);
-
-		// Invalidate post type cache
-		self::invalidateAll($post_type);
-
-		// Invalidate specific post cache
-		self::invalidateAll($post_id);
-		// Clear WordPress core post object cache
-		clean_post_cache($post_id);
-	}
-
-	/**
-	 * Handle post deletion
-	 */
-	public static function onPostDelete(int $post_id): void
-	{
-		$post = get_post($post_id);
-		if (!$post) {
-			return;
-		}
-
-		$post_type = jvbNoBase($post->post_type);
-
-		self::invalidateAll($post_type);
-		self::invalidateAll($post_id);
-		// Clear WordPress core post object cache
-		clean_post_cache($post_id);
-	}
-
-	/**
-	 * Handle term save/update
-	 */
-	public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
-	{
-		// Clear WordPress core term cache
-		clean_term_cache($term_id, $taxonomy);
-		$taxonomy = jvbNoBase($taxonomy);
-
-		// Invalidate taxonomy cache
-		self::invalidateAll($taxonomy);
-
-		// Invalidate specific term cache
-		self::invalidateAll($term_id);
-	}
-
-	/**
-	 * Handle term deletion
-	 */
-	public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
-	{
-		// Clear WordPress core term cache
-		clean_term_cache($term_id, $taxonomy);
-		$taxonomy = jvbNoBase($taxonomy);
-
-		self::invalidateAll($taxonomy);
-		self::invalidateAll($term_id);
-	}
-
-	/**
-	 * Handle user update
-	 */
-	public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void
-	{
-		// Invalidate user-specific cache
-		self::invalidateAll($user_id);
-
-		// Invalidate user role caches if roles changed
-		if ($old_user_data) {
-			$user = get_userdata($user_id);
-			if ($user && $user->roles !== $old_user_data->roles) {
-				foreach (array_merge($user->roles, $old_user_data->roles) as $role) {
-					self::invalidateAll($role);
-				}
-			}
-		}
-		// Clear WordPress core user cache
-		clean_user_cache($user_id);
-	}
-
-	/**
-	 * Handle user deletion
-	 */
-	public static function onUserDelete(int $user_id): void
-	{
-		self::invalidateAll($user_id);
-		// Clear WordPress core user cache
-		clean_user_cache($user_id);
-	}
-
-	/**
-	 * Handle post meta updates
-	 */
-	public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void
-	{
-		if (!str_starts_with($meta_key, BASE)) {
-			return;
-		}
-
-		$post = get_post($post_id);
-		if (!$post) {
-			return;
-		}
-
-		self::onPostSave($post_id, $post);
-	}
-	public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void
-	{
-		if (!str_starts_with($meta_key, BASE)) {
-			return;
-		}
-
-		$post = get_post($post_id);
-		if (!$post) {
-			return;
-		}
-
-		self::onPostSave($post_id, $post);
-	}
-
-	/**
-	 * Handle term meta updates
-	 */
-	public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void
-	{
-		if (!str_starts_with($meta_key, BASE)) {
-			return;
-		}
-
-		$term = get_term($term_id);
-		if (!$term || is_wp_error($term)) {
-			return;
-		}
-
-		self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
-	}
-
-	public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void
-	{
-		if (!str_starts_with($meta_key, BASE)) {
-			return;
-		}
-
-		$term = get_term($term_id);
-		if (!$term || is_wp_error($term)) {
-			return;
-		}
-
-		self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
-	}
-
-		/**
-	 * Handle user meta updates
-	 */
-	public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void
-	{
-		if (!str_starts_with($meta_key, BASE)) {
-			return;
-		}
-
-		$user = get_userdata($user_id);
-		if (!$user) {
-			return;
-		}
-
-		self::onUserUpdate($user_id, null);
-	}
-
-	public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void
-	{
-		if (!str_starts_with($meta_key, BASE)) {
-			return;
-		}
-
-		$user = get_userdata($user_id);
-		if (!$user) {
-			return;
-		}
-
-		self::onUserUpdate($user_id, null);
-	}
-}
diff --git a/inc/managers/CacheManagerOld.php b/inc/managers/CacheManagerOld.php
index 38297f6..d1cf099 100644
--- a/inc/managers/CacheManagerOld.php
+++ b/inc/managers/CacheManagerOld.php
@@ -2,30 +2,296 @@
 namespace JVBase\managers;
 
 if (!defined('ABSPATH')) {
-	exit; // Exit if accessed directly
+	exit;
 }
 
-class CacheManagerOld
+/**
+ * Manages HTTP cache timestamps and relationship-based invalidation
+ *
+ * Data caching: Use wrapper methods or wp_cache_get/set directly
+ * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
+ */
+class CacheManager
 {
-	private string $prefix = 'jvb_';
+	private const CONNECTIONS_OPTION = BASE.'cache_connections';
+	private static ?array $connections_cache = null; // Cache in memory
+	private string $prefix = BASE;
 	private string $group;
 	private int $cache_ttl;
 	private static ?bool $use_object_cache = null;
+	private static array $instances = []; // Cache instances per type
+	private static array $http_timestamps = []; // Request-level memory cache
+	private static ?CacheManager $singleton = null;
 
 	/**
-	 * @param string|null $group The group name for this cache instance
-	 * @param int|null $ttl The default ttl for this instance
+	 * Private constructor - use for() factory method instead
 	 */
-	public function __construct(?string $group = null, ?int $ttl = null)
+	private function __construct(string $group, ?int $ttl = null)
 	{
-		$this->group = $group ?: 'jvb_default';
+		$this->group = jvbNoBase($group);
 		$this->cache_ttl = $ttl ?: 3600;
 
-		// Check if Redis/Memcached is available
 		if (is_null(static::$use_object_cache)) {
-			static::$use_object_cache = !is_null(wp_using_ext_object_cache());
-//			error_log((static::$use_object_cache) ? 'Using Object Cache' : 'Not using Object Cache');
+			static::$use_object_cache = wp_using_ext_object_cache();
 		}
+
+		add_action('init', [$this, 'registerHooks']);
+	}
+
+	/**
+	 * Get singleton instance (for general cache operations)
+	 * For type-specific operations, use for() or forUser() instead
+	 */
+	public static function getInstance(): self
+	{
+		if (self::$singleton === null) {
+			self::$singleton = new self('global', HOUR_IN_SECONDS);
+		}
+		return self::$singleton;
+	}
+
+	/**
+	 * Get all cache connections (public accessor)
+	 *
+	 * @return array Array of cache group connections
+	 */
+	public static function getAllConnections(): array
+	{
+		return self::getConnections();
+	}
+
+	/**
+	 * Get all registered cache groups
+	 *
+	 * @return array List of cache group names
+	 */
+	public static function getAllGroups(): array
+	{
+		$connections = self::getConnections();
+		return array_keys($connections);
+	}
+	/**
+	 * Register WordPress hooks for automatic cache invalidation
+	 * Call this once during plugin initialization
+	 */
+	public static function registerHooks(): void
+	{
+		// Post updates (all post types including core)
+		add_action('save_post', [self::class, 'onPostSave'], 10, 2);
+		add_action('delete_post', [self::class, 'onPostDelete']);
+		// Meta updates (will catch MetaManager updates)
+		add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+		add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+		add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4);
+		// transition_post_status?
+
+		// Term updates (all taxonomies)
+		add_action('edited_term', [self::class, 'onTermSave'], 10, 3);
+		add_action('create_term', [self::class, 'onTermSave'], 10, 3);
+		add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
+
+		// Term meta updates
+		add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+		add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+		add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4);
+
+		// User updates
+		add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2);
+		add_action('user_register', [self::class, 'onUserUpdate'], 10, 1);
+		add_action('deleted_user', [self::class, 'onUserDelete']);
+
+		// User meta updates
+		add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+		add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+		add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4);
+	}
+
+	/**
+	 * Get or create a cache manager instance for a content type
+	 *
+	 * @param string $type Content type (tattoo, style, etc.)
+	 * @param int|null $ttl Optional TTL override
+	 * @return self Fluent interface
+	 */
+	public static function for(string $type, ?int $ttl = null): self
+	{
+		$type = jvbNoBase($type);
+		$key = $type . ($ttl ? "_{$ttl}" : '');
+
+		if (!isset(self::$instances[$key])) {
+			self::$instances[$key] = new self($type, $ttl);
+		}
+
+		return self::$instances[$key];
+	}
+
+	/**
+	 * Get cache manager for a specific user
+	 * Each user gets their own cache group for complete isolation
+	 *
+	 * @param int $user_id User ID
+	 * @param int|null $ttl Optional TTL
+	 * @return self
+	 */
+	public static function forUser(int $user_id, ?int $ttl = null): self
+	{
+		return self::for("user_{$user_id}", $ttl);
+	}
+
+	/**
+	 * Get HTTP cache timestamp for content type(s)
+	 * Used for ETag and Last-Modified header generation
+	 *
+	 * @param string|array $types Single type or array of types
+	 * @return int Latest timestamp (Unix time)
+	 */
+	public static function getTimestamp(string|array $types): int
+	{
+		// Multiple types - return latest
+		if (is_array($types)) {
+			$latest = 0;
+			foreach ($types as $type) {
+				$timestamp = self::getTimestamp($type);
+				if ($timestamp > $latest) {
+					$latest = $timestamp;
+				}
+			}
+			return $latest ?: time();
+		}
+
+		$type = jvbNoBase($types);
+
+		// Check request-level cache
+		if (isset(self::$http_timestamps[$type])) {
+			return self::$http_timestamps[$type];
+		}
+
+		// Load from cache (Redis or transient - wp_cache handles it)
+		$timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time();
+
+		// Cache in memory for this request
+		self::$http_timestamps[$type] = $timestamp;
+
+		return $timestamp;
+	}
+
+	/**
+	 * Update HTTP cache timestamp (marks content as modified)
+	 *
+	 * @param string $type Content type
+	 * @return int The new timestamp
+	 */
+	public static function updateTimestamp(string $type): int
+	{
+		$type = jvbNoBase($type);
+		$timestamp = time();
+
+		// Store (Redis or transient - wp_cache handles it)
+		wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS);
+
+		// Update request cache
+		self::$http_timestamps[$type] = $timestamp;
+
+		do_action('jvb_http_timestamp_updated', $type, $timestamp);
+
+		return $timestamp;
+	}
+
+	/**
+	 * Invalidate cache for a content type
+	 *
+	 * @param string $type Content type to invalidate
+	 * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
+	 * @param bool $flush_connections Whether to flush connected caches
+	 * @return void
+	 */
+	public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void
+	{
+		$type = jvbNoBase($type);
+
+		// Update HTTP timestamp
+		self::updateTimestamp($type);
+
+		// If specific keys provided, only delete those
+		if ($specific_keys !== null) {
+			$instance = self::for($type);
+			if (is_array($specific_keys)) {
+				foreach ($specific_keys as $key) {
+					$instance->delete($key);
+				}
+			} else {
+				$instance->delete($specific_keys);
+			}
+		} else {
+			// Flush the entire group
+			if (function_exists('wp_cache_flush_group')) {
+				wp_cache_flush_group($type);
+			} else {
+				wp_cache_flush();
+			}
+		}
+
+		// Flush connected caches
+		if ($flush_connections) {
+			self::for($type)->connections();
+		}
+
+		do_action('jvb_cache_invalidated', $type);
+	}
+
+	/**
+	 * Invalidate only specific keys for a type (doesn't flush group or update timestamp)
+	 * Use this when you want surgical cache invalidation
+	 *
+	 * @param string $type Content type
+	 * @param string|array $keys Key(s) to delete
+	 * @return void
+	 */
+	public static function invalidateKeys(string $type, string|array $keys): void
+	{
+		$instance = self::for($type);
+
+		if (is_array($keys)) {
+			foreach ($keys as $key) {
+				$instance->delete($key);
+			}
+		} else {
+			$instance->delete($keys);
+		}
+	}
+
+	/**
+	 * Fluent instance method to invalidate this cache type
+	 *
+	 * @param string|array|null $specific_keys Optional specific key(s)
+	 * @param bool $flush_connections Whether to flush connected caches
+	 * @return self For chaining
+	 */
+	public function invalidate($specific_keys = null, bool $flush_connections = true): self
+	{
+		self::invalidateAll($this->group, $specific_keys, $flush_connections);
+		return $this;
+	}
+
+	/**
+	 * Get the HTTP timestamp for this instance's type
+	 *
+	 * @return int
+	 */
+	public function timestamp(): int
+	{
+		return self::getTimestamp($this->group);
+	}
+
+	/**
+	 * Update the HTTP timestamp for this instance's type
+	 *
+	 * @return self For chaining
+	 */
+	public function touch(): self
+	{
+		self::updateTimestamp($this->group);
+		return $this;
 	}
 
 	/**
@@ -37,39 +303,17 @@
 	public function get(string|array $key, ?string $group = null): mixed
 	{
 		$group = $group ?: $this->group;
-
 		$key = $this->normalizeKey($key);
-
 		$cache_key = $this->buildKey($key);
 
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			$value = wp_cache_get($cache_key, $group);
-		} else {
-			// Fallback to transients for local development
-			$value = get_transient($this->getTransientKey($cache_key, $group));
+		$value = wp_cache_get($cache_key, $group);
+
+		// Fallback to transient if no external object cache
+		if ($value === false && !wp_using_ext_object_cache()) {
+			$value = get_transient($group . '_' . $cache_key);
 		}
 
-		return (is_array($value) && array_key_exists('data', $value)) ? $value['data'] : $value;
-	}
-
-	public function getTimestamp(string|array $key, ?string $group = null): mixed
-	{
-		$group = $group ?: $this->group;
-
-		$key = $this->normalizeKey($key);
-
-		$cache_key = $this->buildKey($key);
-
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			$value = wp_cache_get($cache_key, $group);
-		} else {
-			// Fallback to transients for local development
-			$value = get_transient($this->getTransientKey($cache_key, $group));
-		}
-
-		return (is_array($value) && array_key_exists('last_modified', $value)) ? $value['last_modified'] : false;
+		return $value;
 	}
 
 	/**
@@ -84,25 +328,21 @@
 	{
 		$ttl = $ttl ?: $this->cache_ttl;
 		$group = $group ?: $this->group;
-
 		$key = $this->normalizeKey($key);
-
 		$cache_key = $this->buildKey($key);
-		$temp = [
-			'data' => $value,
-			'last_modified' => time(),
-		];
-		$value = $temp;
 
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			return wp_cache_set($cache_key, $value, $group, $ttl);
-		} else {
-			// Fallback to transients
-			return set_transient($this->getTransientKey($cache_key, $group), $value, $ttl);
+		self::updateTimestamp($this->group);
+
+		// Try object cache first
+		$result = wp_cache_set($cache_key, $value, $group, $ttl);
+
+		// If no external object cache, also store in transient for persistence
+		if (!wp_using_ext_object_cache()) {
+			set_transient($group . '_' . $cache_key, $value, $ttl);
 		}
-	}
 
+		return $result;
+	}
 	/**
 	 * Delete a cached value
 	 * @param string|array $key The key to look up (auto-generates key from array of key=>values)
@@ -112,147 +352,60 @@
 	public function delete(string|array $key, ?string $group = null): bool
 	{
 		$group = $group ?: $this->group;
-
 		$key = $this->normalizeKey($key);
-
 		$cache_key = $this->buildKey($key);
 
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			return wp_cache_delete($cache_key, $group);
-		} else {
-			return delete_transient($this->getTransientKey($cache_key, $group));
+		$result = wp_cache_delete($cache_key, $group);
+
+		// Also delete transient if no external object cache
+		if (!wp_using_ext_object_cache()) {
+			delete_transient($group . '_' . $cache_key);
 		}
+
+		return $result;
 	}
 
-	public function clear():bool
+
+	/**
+	 * Clear all cache for this group
+	 * @return bool
+	 */
+	public function clear(): bool
 	{
 		try {
-			if (static::$use_object_cache) {
-				// With Redis, this could be implemented with SCAN command
-				// but wp_cache_* doesn't expose this, so we'd need direct Redis access
-				// For now, just flush the group as a nuclear option
-				if (function_exists('wp_cache_flush_group')) {
-					wp_cache_flush_group($this->group);
-					return true;
-				}
-				return false;
-			} else {
-				// For transients, search and delete
-				global $wpdb;
-
-				$prefix = self::getTransientPrefix($this->group);
-				$sql = "SELECT option_name FROM {$wpdb->options}
-                    WHERE option_name LIKE %s
-                    AND option_name LIKE %s";
-
-				$keys = $wpdb->get_col($wpdb->prepare(
-					$sql,
-					'_transient_' . $prefix . '%'
-				));
-
-				foreach ($keys as $key) {
-					$transient_key = str_replace('_transient_', '', $key);
-					delete_transient($transient_key);
-				}
-				return true;
+			if (function_exists('wp_cache_flush_group')) {
+				wp_cache_flush_group($this->group);
 			}
-		} catch (\Exception $e) {
 
-		} finally {
+			// Clear transients for this group if no external object cache
+			if (!wp_using_ext_object_cache()) {
+				$this->clearGroupTransients();
+			}
+
+			self::updateTimestamp($this->group);
+			return true;
+		} catch (\Exception $e) {
 			return false;
 		}
 	}
 
 	/**
-	 * Alias for delete() for backwards compatibility
-	 * @param string $key The key to look up (auto-generates key from array of key=>values)
-	 * @param string|null $group The group to delete from (defaults to current group))
-	 * @return void
+	 * Clear all transients for this cache group
 	 */
-	public function invalidate(string $key, ?string $group = null): void
+	private function clearGroupTransients(): void
 	{
-		$this->delete($key, $group);
-	}
+		global $wpdb;
 
-	/**
-	 * Clear all cache entries for a group
-	 * @param string $group The group to clear
-	 * @return bool
-	 */
-	public static function invalidateGroup(string $group): bool
-	{
-		$group = jvbNoBase($group);
+		$pattern = '_transient_' . $this->group . '_' . $this->prefix . '%';
+		$timeout_pattern = '_transient_timeout_' . $this->group . '_' . $this->prefix . '%';
 
-		if (wp_using_ext_object_cache()) {
-			// With Redis/Memcached, use native group flush
-			if (function_exists('wp_cache_flush_group')) {
-				return wp_cache_flush_group($group);
-			} else {
-				// Fallback for older WP versions - flush everything (not ideal)
-				return wp_cache_flush();
-			}
-		} else {
-			// For transients, we need to delete them from database
-			global $wpdb;
-
-			$prefix = self::getTransientPrefix($group);
-
-			// Delete transients and their timeouts
-			$sql = "DELETE FROM {$wpdb->options}
-                    WHERE option_name LIKE %s
-                    OR option_name LIKE %s";
-
-			$result = $wpdb->query($wpdb->prepare(
-				$sql,
-				'_transient_' . $prefix . '%',
-				'_transient_timeout_' . $prefix . '%'
-			));
-
-			return $result !== false;
-		}
-	}
-
-	/**
-	 * Clear cache entries by pattern (only works efficiently with Redis)
-	 * @param string $pattern
-	 * @return int
-	 */
-	public function clearPattern(string $pattern): int
-	{
-		$count = 0;
-
-		if (static::$use_object_cache) {
-			// With Redis, this could be implemented with SCAN command
-			// but wp_cache_* doesn't expose this, so we'd need direct Redis access
-			// For now, just flush the group as a nuclear option
-			if (function_exists('wp_cache_flush_group')) {
-				wp_cache_flush_group($this->group);
-				return $count;
-			}
-		} else {
-			// For transients, search and delete
-			global $wpdb;
-
-			$prefix = self::getTransientPrefix($this->group);
-			$sql = "SELECT option_name FROM {$wpdb->options}
-                    WHERE option_name LIKE %s
-                    AND option_name LIKE %s";
-
-			$keys = $wpdb->get_col($wpdb->prepare(
-				$sql,
-				'_transient_' . $prefix . '%',
-				'%' . $pattern . '%'
-			));
-
-			foreach ($keys as $key) {
-				$transient_key = str_replace('_transient_', '', $key);
-				delete_transient($transient_key);
-				$count++;
-			}
-		}
-
-		return $count;
+		$wpdb->query(
+			$wpdb->prepare(
+				"DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s",
+				$pattern,
+				$timeout_pattern
+			)
+		);
 	}
 
 	/**
@@ -289,22 +442,18 @@
 	{
 		$group = $group ?: $this->group;
 		$ttl = $ttl ?: $this->cache_ttl;
-
 		$key = $this->normalizeKey($key);
 
 		$value = $this->get($key, $group);
+
 		if ($value === false) {
 			$value = $callback();
-			if ($value !== false) {
-				$value = [
-					'data' => $value,
-					'last_modified' => time(),
-				];
+			if ($value !== false && $value !== null) {
 				$this->set($key, $value, $ttl, $group);
 			}
 		}
 
-		return (is_array($value) && array_key_exists('data', $value)) ? $value['data']: $value;
+		return $value;
 	}
 
 	/**
@@ -318,59 +467,390 @@
 	}
 
 	/**
-	 * Get transient key for fallback mode
-	 * @param string $key
-	 * @param string $group
-	 * @return string
+	 * Get instance group name (for debugging)
 	 */
-	private function getTransientKey(string $key, string $group): string
+	public function getGroup(): string
 	{
-		// Transients have a 172 character limit
-		$full_key = $group . '_' . $key;
+		return $this->group;
+	}
 
-		if (strlen($full_key) > 160) {
-			// Use hash for long keys, but keep group prefix for clearPattern()
-			return substr($group, 0, 20) . '_' . md5($full_key);
+
+	/***************************************************************************
+	 * CONNECTIONS
+	 * Connect to other caches by instantiating and defining connection
+	 * Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern)
+	 * Where: 	$type = content / taxonomy / user
+	 * 			$scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role)
+	 * 			$keyPattern = ??
+	 ***************************************************************************/
+	/**
+	 * Define a connection between cache groups
+	 * Connected caches will have their ID-based keys deleted when this cache invalidates
+	 *
+	 * @param string $type Grand overview ('post', 'taxonomy', 'user')
+	 * @param string $scope Type-specific constant, user role, or 'id'
+	 * @return self For chaining
+	 */
+	public function connectTo(string $type, string $scope = 'id'): self
+	{
+		//TODO: Handle connect to where $type === 'all'
+		$connections = self::getConnections();
+
+		if (!isset($connections[$this->group])) {
+			$connections[$this->group] = [];
 		}
 
-		return $full_key;
-	}
+		$new_connection = [
+			'parent' => $type,
+			'scope' => $scope
+		];
 
-	/**
-	 * Get transient prefix for a group
-	 */
-	private static function getTransientPrefix(string $group): string
-	{
-		return $group . '_jvb_';
-	}
-
-	/**
-	 * Check if using object cache
-	 */
-	public function isUsingObjectCache(): bool
-	{
-		return static::$use_object_cache;
-	}
-
-
-	/**
-	 * Cleanup expired transients (maintenance method for non-Redis environments)
-	 */
-	public static function cleanupExpiredTransients(): int
-	{
-		if (wp_using_ext_object_cache()) {
-			return 0; // Not needed with Redis
+		// Check if already exists
+		foreach ($connections[$this->group] as $existing) {
+			if ($existing === $new_connection) {
+				return $this;
+			}
 		}
 
-		global $wpdb;
+		$connections[$this->group][] = $new_connection;
+		update_option(self::CONNECTIONS_OPTION, $connections, false);
+		self::$connections_cache = $connections;
 
-		// Delete expired transients
-		$sql = "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
-                WHERE a.option_name LIKE '_transient_%'
-                AND a.option_name NOT LIKE '_transient_timeout_%'
-                AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
-                AND b.option_value < %d";
+		return $this;
+	}
 
-		return $wpdb->query($wpdb->prepare($sql, time()));
+	/**
+	 * Get all registered connections (cached for performance)
+	 *
+	 * @param bool $refresh Force refresh from database
+	 * @return array
+	 */
+	private static function getConnections(bool $refresh = false): array
+	{
+		if (self::$connections_cache === null || $refresh) {
+			self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []);
+		}
+
+		return self::$connections_cache;
+	}
+
+	/**
+	 * Flush all caches connected to this one
+	 *
+	 * @return self For chaining
+	 */
+	public function connections(): self
+	{
+		$all_connections = self::getConnections();
+
+		foreach ($all_connections as $cache_group => $connections) {
+			foreach ($connections as $conn) {
+				if ($this->matchesConnection($conn)) {
+					$this->flushConnection($cache_group, $conn);
+				}
+			}
+		}
+
+		return $this;
+	}
+
+	/**
+	 * Check if this cache group matches a connection definition
+	 */
+	private function matchesConnection(array $connection): bool
+	{
+		$parent = $connection['parent'] ?? '';
+		$scope = $connection['scope'] ?? 'id';
+
+		// Grand overview match
+		if ($this->group === $parent) {
+			return true;
+		}
+
+		// Type-specific match
+		if ($scope !== 'id') {
+			if ($this->group === jvbNoBase($scope)) {
+				return true;
+			}
+
+			// Check constants
+			if ($parent === 'post' && defined('JVB_CONTENT')) {
+				return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group;
+			}
+
+			if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) {
+				return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group;
+			}
+		}
+
+		// ID-specific match: 'user_123' matches 'user' + 'id'
+		if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) {
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Flush a connected cache group
+	 * For ID-specific connections, deletes the specific ID key
+	 * For type/overview connections, flushes entire group
+	 */
+	private function flushConnection(string $cache_group, array $connection): void
+	{
+		$scope = $connection['scope'] ?? 'id';
+
+		// ID-specific: delete specific key
+		if ($scope === 'id') {
+			$id = $this->extractIdFromGroup();
+
+			if ($id !== null) {
+				self::invalidateKeys($cache_group, $id);
+				return;
+			}
+		}
+
+		// Type/overview: flush entire group
+		self::invalidateAll($cache_group, specific_keys: null, flush_connections: false);
+	}
+
+	/**
+	 * Extract ID from group name like 'user_123' -> '123'
+	 *
+	 * @return string|null
+	 */
+	private function extractIdFromGroup(): ?string
+	{
+		if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) {
+			return $matches[1];
+		}
+
+		return null;
+	}
+
+	/**
+	 * Register multiple connections at once
+	 */
+	public static function registerConnections(array $connections): void
+	{
+		$existing = self::getConnections();
+		$changed = false;
+
+		foreach ($connections as $cache_group => $configs) {
+			if (!isset($existing[$cache_group])) {
+				$existing[$cache_group] = [];
+			}
+
+			foreach ($configs as $config) {
+				$duplicate = false;
+				foreach ($existing[$cache_group] as $existing_config) {
+					if ($existing_config === $config) {
+						$duplicate = true;
+						break;
+					}
+				}
+
+				if (!$duplicate) {
+					$existing[$cache_group][] = $config;
+					$changed = true;
+				}
+			}
+		}
+
+		if ($changed) {
+			update_option(self::CONNECTIONS_OPTION, $existing, false);
+			self::$connections_cache = $existing;
+		}
+	}
+
+	/**
+	 * Handle post save/update
+	 */
+	public static function onPostSave(int $post_id, \WP_Post $post): void
+	{
+		// Skip revisions and autosaves
+		if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
+			return;
+		}
+
+		$post_type = jvbNoBase($post->post_type);
+
+		// Invalidate post type cache
+		self::invalidateAll($post_type);
+
+		// Invalidate specific post cache
+		self::invalidateAll($post_id);
+		// Clear WordPress core post object cache
+		clean_post_cache($post_id);
+	}
+
+	/**
+	 * Handle post deletion
+	 */
+	public static function onPostDelete(int $post_id): void
+	{
+		$post = get_post($post_id);
+		if (!$post) {
+			return;
+		}
+
+		$post_type = jvbNoBase($post->post_type);
+
+		self::invalidateAll($post_type);
+		self::invalidateAll($post_id);
+		// Clear WordPress core post object cache
+		clean_post_cache($post_id);
+	}
+
+	/**
+	 * Handle term save/update
+	 */
+	public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
+	{
+		// Clear WordPress core term cache
+		clean_term_cache($term_id, $taxonomy);
+		$taxonomy = jvbNoBase($taxonomy);
+
+		// Invalidate taxonomy cache
+		self::invalidateAll($taxonomy);
+
+		// Invalidate specific term cache
+		self::invalidateAll($term_id);
+	}
+
+	/**
+	 * Handle term deletion
+	 */
+	public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
+	{
+		// Clear WordPress core term cache
+		clean_term_cache($term_id, $taxonomy);
+		$taxonomy = jvbNoBase($taxonomy);
+
+		self::invalidateAll($taxonomy);
+		self::invalidateAll($term_id);
+	}
+
+	/**
+	 * Handle user update
+	 */
+	public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void
+	{
+		// Invalidate user-specific cache
+		self::invalidateAll($user_id);
+
+		// Invalidate user role caches if roles changed
+		if ($old_user_data) {
+			$user = get_userdata($user_id);
+			if ($user && $user->roles !== $old_user_data->roles) {
+				foreach (array_merge($user->roles, $old_user_data->roles) as $role) {
+					self::invalidateAll($role);
+				}
+			}
+		}
+		// Clear WordPress core user cache
+		clean_user_cache($user_id);
+	}
+
+	/**
+	 * Handle user deletion
+	 */
+	public static function onUserDelete(int $user_id): void
+	{
+		self::invalidateAll($user_id);
+		// Clear WordPress core user cache
+		clean_user_cache($user_id);
+	}
+
+	/**
+	 * Handle post meta updates
+	 */
+	public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$post = get_post($post_id);
+		if (!$post) {
+			return;
+		}
+
+		self::onPostSave($post_id, $post);
+	}
+	public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$post = get_post($post_id);
+		if (!$post) {
+			return;
+		}
+
+		self::onPostSave($post_id, $post);
+	}
+
+	/**
+	 * Handle term meta updates
+	 */
+	public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$term = get_term($term_id);
+		if (!$term || is_wp_error($term)) {
+			return;
+		}
+
+		self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+	}
+
+	public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$term = get_term($term_id);
+		if (!$term || is_wp_error($term)) {
+			return;
+		}
+
+		self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+	}
+
+	/**
+	 * Handle user meta updates
+	 */
+	public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$user = get_userdata($user_id);
+		if (!$user) {
+			return;
+		}
+
+		self::onUserUpdate($user_id, null);
+	}
+
+	public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$user = get_userdata($user_id);
+		if (!$user) {
+			return;
+		}
+
+		self::onUserUpdate($user_id, null);
 	}
 }
diff --git a/inc/managers/CloudflareTurnstile.php b/inc/managers/CloudflareTurnstile.php
deleted file mode 100644
index 47b8d4d..0000000
--- a/inc/managers/CloudflareTurnstile.php
+++ /dev/null
@@ -1,218 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use WP_User;
-use WP_Error;
-
-if (!defined('ABSPATH')) {
-    exit; // Exit if accessed directly
-}
-/**
- * Cloudflare Turnstile Integration for WordPress
- *
- * Adds Turnstile protection to login and registration forms
- */
-
-class CloudflareTurnstile
-{
-
-    private string $site_key;
-    private string $secret_key;
-
-    /**
-     * Constructor
-     */
-    public function __construct()
-    {
-		return;
-        // Set your Cloudflare Turnstile keys here
-
-//        $this->site_key = JVB_CLOUDFLARE_SITE_KEY;
-//        $this->secret_key = JVB_CLOUDFLARE_SECRET_KEY;
-
-        // Add hooks for login and registration forms
-        add_action('login_enqueue_scripts', [$this, 'enqueueTurnstileScripts']);
-        add_action('login_form', [$this, 'addTurnstileToLogin']);
-        add_action('register_form', [$this, 'addTurnstileToRegister']);
-
-        // Add verification hooks
-        add_filter('authenticate', [$this, 'verifyLoginTurnstile'], 99, 3);
-        add_filter('registration_errors', [$this, 'verifyRegisterTurnstile'], 10, 3);
-
-        // Add hook for lost password form
-        add_action('lostpassword_form', [$this, 'addTurnstileToLogin']);
-        add_action('lostpassword_post', [$this, 'verifyLostpasswordTurnstile']);
-    }
-
-    /**
-     * Enqueue Turnstile script
-     * @return void
-     */
-    public function enqueueTurnstileScripts():void
-    {
-        wp_enqueue_script(
-            'cloudflare-turnstile',
-            'https://challenges.cloudflare.com/turnstile/v0/api.js',
-            [],
-            null,
-            true
-        );
-        // Add this line to set the async and defer attributes
-        wp_script_add_data('cloudflare-turnstile', 'async', true);
-        wp_script_add_data('cloudflare-turnstile', 'defer', true);
-    }
-
-    /**
-     * Add Turnstile to login form
-     * @return void
-     */
-    public function addTurnstileToLogin():void
-    {
-        echo '<div class="cf-turnstile-wrapper" style="margin: 1em 0;">';
-        echo '<div class="cf-turnstile" data-sitekey="' . esc_attr($this->site_key) . '" data-theme="light"></div>';
-        echo '</div>';
-    }
-
-    /**
-     * Add Turnstile to registration form
-     * @return void
-     */
-    public function addTurnstileToRegister():void
-    {
-        echo '<div class="cf-turnstile-wrapper" style="margin: 1em 0;">';
-        echo '<div class="cf-turnstile" data-sitekey="' . esc_attr($this->site_key) . '" data-theme="light"></div>';
-        echo '<style>.register .cf-turnstile-wrapper { clear: both; }</style>';
-        echo '</div>';
-    }
-
-    /**
-     * Verify Turnstile token
-     * @param $token
-     *
-     * @return bool
-     */
-    public function verifyTurnstile($token = null):bool
-    {
-        // If no token is provided, try to get it from the request
-        if (!$token && isset($_POST['cf-turnstile-response'])) {
-            $token = $_POST['cf-turnstile-response'];
-        }
-
-        // If still no token, verification fails
-        if (!$token) {
-            return false;
-        }
-
-        $data = [
-            'secret' => $this->secret_key,
-            'response' => $token,
-            'remoteip' => $_SERVER['REMOTE_ADDR']
-        ];
-
-        $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
-        $response = wp_remote_post($url, [
-            'body' => $data,
-            'timeout' => 30
-        ]);
-
-
-
-        if (is_wp_error($response)) {
-            return false;
-        }
-
-        $body = wp_remote_retrieve_body($response);
-        $result = json_decode($body, true);
-
-        return isset($result['success']) && $result['success'] === true;
-    }
-
-    /**
-     * Verify login form
-     * @param null|WP_User|WP_Error $user
-     * @param string $username
-     * @param string $password
-     *
-     * @return WP_Error|WP_User
-     */
-    public function verifyLoginTurnstile(null|WP_User|WP_Error $user, string $username, string $password):WP_Error|WP_User
-    {
-        global $_POST;
-        // Skip verification if already logged in or if no username/password
-        if (is_user_logged_in() || empty($username) || empty($password)) {
-            return $user;
-        }
-
-        // Skip on AJAX requests (for better compatibility with other plugins)
-        if (wp_doing_ajax()) {
-            return $user;
-        }
-
-        // If already have an error, just return it
-        if (is_wp_error($user)) {
-            return $user;
-        }
-
-        // Check Turnstile
-        if (!$this->verifyTurnstile()) {
-            return new WP_Error('turnstile_verification_failed', '<strong>ERROR</strong>: Please complete the security check.');
-        }
-
-        return $user;
-    }
-
-    /**
-     * Verify registration form
-     * @param WP_Error $errors
-     * @param string $sanitized_user_login
-     * @param string $user_email
-     *
-     * @return WP_Error
-     */
-    public function verifyRegisterTurnstile(WP_Error $errors, string $sanitized_user_login, string $user_email):WP_Error
-    {
-        // Skip on AJAX requests (for better compatibility with other plugins)
-        if (wp_doing_ajax()) {
-            return $errors;
-        }
-
-        // Check Turnstile
-        if (!$this->verifyTurnstile()) {
-            $errors->add('turnstile_verification_failed', '<strong>ERROR</strong>: Please complete the security check.');
-        }
-
-        return $errors;
-    }
-
-    /**
-     * Verify lost password form
-     * @param WP_Error $errors
-     *
-     * @return WP_Error
-     */
-    public function verifyLostpasswordTurnstile(WP_Error $errors):WP_Error
-    {
-        // Skip on AJAX requests (for better compatibility with other plugins)
-        if (wp_doing_ajax()) {
-            return $errors;
-        }
-
-        // Check if the form was submitted
-        if (!isset($_POST['user_login']) || empty($_POST['user_login'])) {
-            return $errors ;
-        }
-
-        // Check Turnstile
-        if (!$this->verifyTurnstile()) {
-            $redirect_to = isset($_POST['redirect_to']) ? $_POST['redirect_to'] : '';
-            $errors = new WP_Error('turnstile_verification_failed', '<strong>ERROR</strong>: Please complete the security check.');
-
-            // WP's lost password form handling
-            wp_die($errors->get_error_message(), __('Security Check Failed', 'edmonton-ink'), [
-                'response' => 403,
-                'back_link' => wp_lostpassword_url($redirect_to)
-            ]);
-        }
-        return $errors;
-    }
-}
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index bc12ee2..5a25ea0 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -17,14 +17,14 @@
 class DashboardManager
 {
     protected WP_User $user;
-    protected CacheManager $cache;
+    protected Cache $cache;
     protected string $role;
 	protected string $baseURL;
     protected int $userLink;
 
     public function __construct()
     {
-        $this->cache = CacheManager::for('dashboard', WEEK_IN_SECONDS);
+        $this->cache = Cache::for('dashboard', WEEK_IN_SECONDS)->connect('user');
         add_action('init', [$this, 'registerDashboard']);
         if (!$this->isRegistered()) {
             add_action('init', [$this, 'buildDashboard']);
@@ -210,6 +210,7 @@
 			$page = $this->getCurrentPageTitle();
 			// Check if page exists in allowed pages
 			$allowedPages = $this->getUserAllowedPages();
+
 			if (!in_array($page, $allowedPages)) {
 				error_log("User not allowed to access page: {$page}");
 				$this->redirectToDashboard();
@@ -353,14 +354,14 @@
 			// Pass along to the Integrations template handler which knows to check for subpages
 			$page = 'integrations';
 		}
-		echo $this->renderDashboard($page);
-		//TODO: Reenable
-//		echo $this->cache->remember(
-//			$page,
-//		 	function() use ($page) {
-//				return $this->renderDashboard($page);
-//		 	}
-//		);
+//		echo $this->renderDashboard($page);
+
+		echo $this->cache->remember(
+			$page,
+		 	function() use ($page) {
+				return $this->renderDashboard($page);
+		 	}
+		);
 
 		return '';
     }
@@ -597,8 +598,7 @@
         if (!$post) {
             return '';
         }
-
-        return $post->post_title;
+        return html_entity_decode($post->post_title);
     }
    protected function getCurrentPageSlug():string
     {
@@ -1347,15 +1347,14 @@
 		}
 
 
-		$cacheKey = "user_pages_{$userID}";
-		$pages = $this->cache->get($cacheKey);
-		$pages = false;
+		$pages = $this->cache->get($userID);
+
 		if ($pages === false || JVB_TESTING) {
 			if (user_can($userID, 'manage_options')) {
 				// Admin gets all pages as flat array
 				$pages = $this->getAllDashboardPages();
 				// Extract just the values (slugs)
-				$this->cache->set($cacheKey, $pages, WEEK_IN_SECONDS);
+				$this->cache->set($userID, $pages, WEEK_IN_SECONDS);
 				return $pages;
 			}
 			$roles = array_map('jvbNoBase', $user->roles);
@@ -1477,7 +1476,7 @@
 			$pages = apply_filters('jvbUserDashboardPages', $pages, $user->roles, $userID);
 			$pages = array_unique($pages);
 
-			$this->cache->set($cacheKey, $pages, WEEK_IN_SECONDS);
+			$this->cache->set($userID, $pages, WEEK_IN_SECONDS);
 		}
 
 		return $pages;
@@ -1551,20 +1550,4 @@
 		// Default to edit_{type}s
 		return 'edit_'.$type.'s';
 	}
-
-	/**
-	 * Invalidate dashboard page cache for a user or all users
-	 * Call this when user roles or permissions change
-	 * @param int|null $userID Specific user to invalidate, null for all
-	 * @return void
-	 */
-	public function invalidatePagesCache(?int $userID = null):void
-	{
-		if ($userID !== null) {
-			$this->cache->delete("user_pages_{$userID}");
-		} else {
-			// Invalidate all user caches by invalidating the group
-			$this->cache->invalidate();
-		}
-	}
 }
diff --git a/inc/managers/DirectoryManager.php b/inc/managers/DirectoryManager.php
index d724af6..541ca2c 100644
--- a/inc/managers/DirectoryManager.php
+++ b/inc/managers/DirectoryManager.php
@@ -6,6 +6,7 @@
 }
 
 use JVBase\registry\PostTypeRegistrar;
+use JVBase\managers\Cache;
 use JVBase\utility\Features;
 use WP_Block;
 use WP_Query;
@@ -18,7 +19,7 @@
 	protected int $perPage;
     protected static string $type = BASE.'for_type';
     protected static string $slug = BASE.'for_type_slug';
-    protected CacheManager $cache;
+    protected Cache $cache;
 
     public function __construct($perPage = 100)
     {
@@ -27,18 +28,15 @@
             return;
         }
 		$this->perPage = $perPage;
-        $this->cache = CacheManager::for('directory', WEEK_IN_SECONDS);
+        $this->cache = Cache::for('directory', WEEK_IN_SECONDS);
+		$this->cache->connect('post', true)
+			->connect('taxonomy', true)
+			->connect('user', true);
+
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 
-		foreach(['content','taxonomy','user'] as $key) {
-			if (array_key_exists($key, $this->directories)) {
-				$this->cache->connectTo($key);
-			}
-		}
-
-
 		add_action('init', [$this, 'registerDirectories']);
 		jvb_register_do_once('directories_registered', [$this, 'activate']);
         add_action('render_block', [$this, 'renderBlock'], 99999, 3);
@@ -445,7 +443,7 @@
 										if ( $terms && ! is_wp_error( $terms ) ) {
 											$term    = $terms[0];
 											$extra[] = [
-												'name' => (get_term_meta( $term->term_id, BASE . 'singular', true ) !== '') ? get_term_meta( $term->term_id, BASE . 'singular', true ) : $term->name,
+												'name' => (get_term_meta( $term->term_id, BASE . 'singular', true ) !== '') ? get_term_meta( $term->term_id, BASE . 'singular', true ) : html_entity_decode($term->name),
 												'url'  => get_term_link( $term->term_id, $item ),
 												'id'   => $term->term_id,
 												'type' => $item,
@@ -489,7 +487,7 @@
 
 								$list = $this->alphabetizeMe(
 									$list,
-									$term->name,
+									html_entity_decode($term->name),
 									get_term_link( $term->term_id, jvbCheckBase( $slug ) ),
 									$term->term_id,
 									$extra
@@ -580,11 +578,11 @@
 			$children =$this->renderListChunk($taxonomy, $term->term_id);
 			$out .= '<li>';
 			if ($children !== '') {
-				$out .= '<details class="term"><summary class="row btw"><a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.$term->name.'">'.$term->name.'</a></summary>';
+				$out .= '<details class="term"><summary class="row btw"><a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.html_entity_decode($term->name).'">'.$term->name.'</a></summary>';
 				$out .= $children;
 				$out .= '</details>';
 			} else {
-				$out .= '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.$term->name.'">'.$term->name.'</a>';
+				$out .= '<a href="'.get_term_link($term->term_id, $term->taxonomy).'" title="See more '.$term->name.'">'.html_entity_decode($term->name).'</a>';
 			}
 			$out .= '</li>';
         }
diff --git a/inc/managers/IconsManager.php b/inc/managers/IconsManager.php
index 4415bc7..2f66a45 100644
--- a/inc/managers/IconsManager.php
+++ b/inc/managers/IconsManager.php
@@ -18,7 +18,7 @@
 	// Instance-specific properties
 	protected string $source;
 	protected array $icons = []; // Icons for THIS source [style => [names]]
-	protected CacheManager $cache;
+	protected Cache $cache;
 	protected string $style = 'regular';
 	protected array $styles = ['regular', 'bold', 'duotone', 'fill', 'light', 'thin'];
 	protected array $customIcons = []; // Custom icons for THIS source
@@ -42,7 +42,7 @@
 	private function __construct(string $source)
 	{
 		$this->source = $source;
-		$this->cache = CacheManager::for('icons_' . $source, WEEK_IN_SECONDS);
+		$this->cache = Cache::for('icons_' . $source, WEEK_IN_SECONDS);
 		$this->style = (array_key_exists('icons', JVB_SITE) && in_array(JVB_SITE['icons'], $this->styles))
 			? JVB_SITE['icons']
 			: 'regular';
@@ -385,7 +385,7 @@
 	 */
 	protected function registerGlobalHooks(): void
 	{
-		add_action('init', [$this, 'checkCSS']);
+		add_action('wp_loaded', [self::class, 'checkCSS']);
 	}
 
 	/**
@@ -410,7 +410,7 @@
 		wp_enqueue_style('jvb-icons-'.$this->source);
 	}
 
-	public function checkCSS(): void
+	public static function checkCSS(): void
 	{
 		$needsUpdate = get_option(BASE.'icons_needs_update', []);
 		if (!empty($needsUpdate)) {
@@ -420,7 +420,7 @@
 		}
 	}
 
-	protected static function regenerateAllCSS(array $sourcesToUpdate = []): void
+	public static function regenerateAllCSS(array $sourcesToUpdate = []): void
 	{
 		error_log('[IconsManager]:regenerateCSS');
 		$css_dir = JVB_CHILD_DIR.'/assets/css/';
@@ -429,28 +429,41 @@
 			wp_mkdir_p($css_dir);
 		}
 
+		// Load all icons from database option
+		$allIcons = get_option(BASE.'usedIcons', []);
+
 		// If no specific sources provided, regenerate all
 		if (empty($sourcesToUpdate)) {
-			$sourcesToUpdate = array_fill_keys(array_keys(self::$instances), true);
+			$sourcesToUpdate = array_fill_keys(array_keys($allIcons), true);
 		}
 
-		// Generate CSS only for sources that need it
-		foreach (self::$instances as $source => $instance) {
-			if (!isset($sourcesToUpdate[$source])) {
+		// Generate CSS for each source that needs it
+		foreach ($sourcesToUpdate as $source => $needsUpdate) {
+			if (!$needsUpdate || !isset($allIcons[$source])) {
 				continue;
 			}
 
+			// Get or create instance for this source
+			$instance = self::for($source);
+
+			// Temporarily set icons from database
+			$originalIcons = $instance->icons;
+			$instance->icons = $allIcons[$source];
+
 			$css = $instance->generateIconCSS();
 			$css_path = $css_dir . $source . '.css';
 
 			$instance->archiveCurrentVersion($css);
 
 			if (file_put_contents($css_path, $css) !== false) {
-				CacheManager::updateTimestamp('icons_' . $source);
+				Cache::touch('icons_' . $source);
 				error_log("[IconsManager] Updated {$source}.css");
 			} else {
 				error_log("[IconsManager] Could not write {$source}.css");
 			}
+
+			// Restore original icons
+			$instance->icons = $originalIcons;
 		}
 	}
 
@@ -481,7 +494,7 @@
 			$instance->archiveCurrentVersion($css);
 
 			if (file_put_contents($css_path, $css) !== false) {
-				CacheManager::updateTimestamp('icons_' . $source);
+				Cache::touch('icons_' . $source);
 				error_log("[IconsManager] Updated {$source}.css");
 			} else {
 				error_log("[IconsManager] Could not write {$source}.css");
@@ -516,6 +529,10 @@
 	 */
 	public function get(string $name, array $options = []): string
 	{
+		if ($name === '') {
+			//No icon requested
+			return '';
+		}
 		$style = $options['style'] ?? $this->style;
 		$name = $this->map[$name] ?? $name;
 
@@ -640,7 +657,7 @@
 
 	public function registerStyle(): void
 	{
-		$timestamp = CacheManager::getTimestamp('icons_' . $this->source);
+		$timestamp = Cache::lastModified('icons_' . $this->source);
 		$handle = 'jvb-icons-' . $this->source;
 
 		wp_register_style(
@@ -723,8 +740,7 @@
 
 		// Clear cache for all sources
 		foreach (self::$instances as $source => $instance) {
-			$instance->cache->delete('icon_styles_css');
-			CacheManager::updateTimestamp('icons_' . $source);
+			$instance->cache->forget('icon_styles_css');
 		}
 	}
 
@@ -778,7 +794,7 @@
 				if (file_put_contents($css_path, $entry['css']) !== false) {
 					$this->icons = $entry['iconList'];
 					$this->saveIcons();
-					CacheManager::updateTimestamp('icons_' . $this->source);
+					Cache::touch('icons_' . $this->source);
 					return true;
 				}
 
@@ -799,7 +815,7 @@
 		}
 		$needsUpdate[$this->source] = true;
 		update_option(BASE.'icons_needs_update', $needsUpdate);
-		CacheManager::updateTimestamp('icons_' . $this->source);
+		Cache::touch('icons_' . $this->source);
 	}
 
 	public function mergeVersions(array $timestamps): bool
diff --git a/inc/managers/IconsManagerBackup.php b/inc/managers/IconsManagerBackup.php
deleted file mode 100644
index 285ec6b..0000000
--- a/inc/managers/IconsManagerBackup.php
+++ /dev/null
@@ -1,670 +0,0 @@
-<?php
-namespace JVBase\inc\managers;
-
-use JVBase\managers\CacheManager;
-use JVBase\utility\Features;
-
-if (!defined('ABSPATH')) {
-	exit;
-}
-
-class IconsManagerBackup
-{
-	protected static ?IconsManagerBackup $instance = null;
-	protected CacheManager $cache;
-	protected string $style = 'regular';
-	protected array $styles = ['regular', 'bold', 'duotone', 'fill', 'light', 'thin'];
-	// Custom icons registered via filter
-	protected array $customIcons = [];
-	protected array $usedIcons = [];
-	protected array $map = [];
-	protected const MAX_VERSIONS = 5;
-
-	/**
-	 * Get singleton instance
-	 */
-	public static function getInstance(): IconsManagerBackup
-	{
-		if (self::$instance === null) {
-			self::$instance = new self();
-		}
-		return self::$instance;
-	}
-	private function __construct()
-	{
-		$this->cache = CacheManager::for('icons', WEEK_IN_SECONDS);
-
-		$this->style = (array_key_exists('icons', JVB_SITE) && in_array(JVB_SITE['icons'], $this->styles))
-			? JVB_SITE['icons']
-			: 'regular';
-
-		$this->addMap();
-
-		// Allow custom icon registration
-		$this->customIcons = apply_filters('jvbRegisterCustomIcons', [
-			'syncing'		=> JVB_DIR .'/assets/icons/cloud-sync-thin.svg',
-			'alphabetical'	=> JVB_DIR.'/assets/icons/alphabetical.svg'
-		]);
-
-
-		$this->usedIcons = get_option(BASE.'usedIcons', []);
-		$this->includeIcons();
-		// Track custom icons for CSS generation
-		$this->trackCustomIcons();
-		// Register hooks only once
-		$this->registerHooks();
-	}
-
-	/**
-	 * Ensure custom icons are tracked for CSS generation
-	 */
-	protected function trackCustomIcons(): void
-	{
-		if (empty($this->customIcons)) {
-			return;
-		}
-
-		foreach ($this->customIcons as $name => $path) {
-			$this->trackIconUsage($name, $this->style);
-		}
-	}
-
-	/**
-	 * Include icons via filter (for JS usage, etc.)
-	 */
-	protected function includeIcons():void
-	{
-		$icons = get_option(BASE.'includeIcons');
-
-		if (!$icons) {
-			$icons = [
-				'check-circle',
-				'close-circle',
-				'cloud-slash',
-				'exclamation-mark',
-				'cloud-arrow-down',
-				'cloud-arrow-up',
-				'cloud-check',
-				'cloud-slash',
-				'cloud-warning',
-				'syncing',
-				'cloud-x',
-				'arrows-clockwise',
-				'share-fat',
-				'trash',
-				'star',
-				['name' => 'star-half', 'style' => 'fill'],
-				['name' => 'star', 'style' => 'fill'],
-				//FORMATTING
-				'copy',
-				'paragraph',
-				'text-h-one',
-				'text-h-two',
-				'text-h-three',
-				'text-h-four',
-				'text-h-five',
-				'text-h-six',
-				['name'	=>'text-b', 'style' => 'fill'],
-				'text-italic',
-				'text-underline',
-				'text-strikethrough',
-				'list-dashes',
-				'list-numbers',
-				'text-align-left',
-				'text-align-center',
-				'text-align-right',
-//			'text-align-justify',
-				'link',
-				//FILE ICONS
-				'file-pdf',
-				'file-csv',
-				'file-doc',
-				'file-txt',
-				'file-xls',
-			];
-
-			$check = [JVB_CONTENT, JVB_TAXONOMY, JVB_USER];
-			foreach ($check as $constant) {
-				foreach ($constant as $key => $value) {
-					if (array_key_exists('icon', $value) && !in_array($value['icon'], $icons)) {
-						$icons[] = $value['icon'];
-					}
-				}
-			}
-			$icons = apply_filters('jvbIncludeIcons', $icons);
-			$icons = $this->maybePrefixIcons($icons);
-			update_option(BASE.'includeIcons', $icons);
-		}
-
-		// Ensure icons are in the correct format (handle legacy data)
-		if (!$this->isIconsArrayPrefixed($icons)) {
-			$icons = $this->maybePrefixIcons($icons);
-			update_option(BASE.'includeIcons', $icons);
-		}
-
-		$additional = apply_filters('jvbIncludeIcons', []);
-		if (!empty($additional)) {
-			$additional = $this->maybePrefixIcons($additional);
-			$merged = $this->mergeUsedIcons($icons, $additional);
-
-			if ($icons != $merged) {
-				update_option(BASE.'includeIcons', $merged);
-				$icons = $merged;
-			}
-		}
-
-		foreach ($icons as $style => $theIcons) {
-			foreach($theIcons as $icon) {
-				$this->trackIconUsage($icon, $style);
-			}
-		}
-	}
-
-	/**
-	 * Check if icons array is in the prefixed format [style => [icons]]
-	 */
-	protected function isIconsArrayPrefixed(array $icons): bool
-	{
-		if (empty($icons)) {
-			return true;
-		}
-
-		// Check if first key is a valid style name
-		$first_key = array_key_first($icons);
-		if (!in_array($first_key, $this->styles)) {
-			return false;
-		}
-
-		// Check if first value is an array
-		return is_array($icons[$first_key]);
-	}
-
-	protected function maybePrefixIcons(array $icons):array
-	{
-		$out = [];
-		foreach ($icons as $icon) {
-			if (is_array($icon) && array_key_exists('style', $icon)) {
-				if (!array_key_exists($icon['style'], $out)) {
-					$out[$icon['style']] = [];
-				}
-				if (!in_array($icon['name'], $out[$icon['style']])) {
-					$out[$icon['style']][] = $icon['name'];
-				}
-			} elseif(is_array($icon)) {
-				$icon = $icon['name'];
-			}
-			if (!is_array($icon)) {
-				if (!array_key_exists($this->style, $out)) {
-					$out[$this->style] = [];
-				}
-				if (!in_array($icon, $out[$this->style])){
-					$out[$this->style][] = $icon;
-				}
-			}
-		}
-		return $out;
-	}
-
-	protected function addMap():void
-	{
-		$map = get_option(BASE.'iconMap');
-		if (!$map) {
-			$map = [
-				'seo'	=> 'robot'
-			];
-			if (Features::forSite()->has('referrals')){
-				$map['referrals'] = 'hand-heart';
-			}
-			if (Features::forSite()->has('dashboard')){
-				$map['dash'] = 'door';
-			}
-			if (Features::forSite()->has('magicLink')){
-				$map['magicLink'] = 'magic-wand';
-			}
-			if (Features::hasAnyIntegration()) {
-				$map['integrations'] = 'plugs-connected';
-			}
-
-
-			update_option(BASE.'iconMap', $map);
-		}
-
-		$this->map = apply_filters('jvbMapIcons', $map);
-	}
-
-	/**
-	 * Register WordPress hooks
-	 */
-	protected function registerHooks(): void
-	{
-		add_action('init', [$this, 'includeIcons'], 1);
-		add_action('init', [$this, 'checkCSS'], 10);
-		add_action('wp_enqueue_scripts', [$this, 'enqueueIconStyles']);
-		add_action('admin_enqueue_scripts', [$this, 'enqueueIconStyles']);
-	}
-
-	public function checkCSS():void
-	{
-//		update_option(BASE.'icons_needs_update', true);
-		if (get_option(BASE.'icons_needs_update', false)) {
-			error_log('Regenerating CSS');
-			delete_option(BASE.'icons_needs_update');
-			$this->regenerateCSS();
-		}
-	}
-
-	protected function regenerateCSS(): void
-	{
-		error_log('[IconsManager]:regenerateCSS');
-		$css_dir = JVB_CHILD_DIR.'/assets/css/';
-		if (!file_exists($css_dir)) {
-			wp_mkdir_p($css_dir);
-		}
-
-		// Generate CSS for each source
-		foreach ($this->usedIcons as $source => $styles) {
-			$css = $this->generateIconCSS($source);
-			$css_path = $css_dir . $source . '.css';
-
-			$this->archiveCurrentVersion($css, $source);
-
-			if (file_put_contents($css_path, $css) !== false) {
-				CacheManager::updateTimestamp('icons_' . $source);
-			} else {
-				error_log("[IconsManager] Could not write {$source}.css");
-			}
-		}
-	}
-
-	/**
-	 * Prevent cloning
-	 */
-	private function __clone() {}
-
-	/**
-	 * Prevent unserialization
-	 */
-	public function __wakeup()
-	{
-		throw new \Exception("Cannot unserialize singleton");
-	}
-
-	/**
-	 * Get an icon element
-	 *
-	 * @param string $name Icon name (e.g., 'heart', 'calendar')
-	 * @param array $options Options array:
-	 *   - 'style' => 'regular'|'bold'|'fill'|etc.
-	 *   - 'label' => 'Accessible label' (for standalone icons)
-	 *   - 'decorative' => true (for icons next to text)
-	 *   - 'class' => 'additional classes'
-	 *   - 'size' => 24 (for custom sizing via inline style)
-	 * @return string HTML icon element
-	 */
-	public function getIcon(string $name, array $options = []): string
-	{
-		$style = array_key_exists('style', $options) ? $options['style'] :$this->style;
-		$source = $options['source'] ?? 'icons';
-		$name = (array_key_exists($name, $this->map)) ? $this->map[$name] : $name;
-
-		// Validate icon exists
-		if (!$this->iconExists($name, $style)) {
-			error_log('[IconsManager] Icon not found: ' . $name);
-			return '';
-		}
-
-
-
-		// Track icon usage
-		$this->trackIconUsage($name, $style, $source);
-
-		$styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : '';
-		// Build classes
-		$classes = ['icon', 'icon-' . $name.$styleClass];
-		if (!empty($options['class'])) {
-			$classes[] = $options['class'];
-		}
-
-
-		$attrs = ['class="' . esc_attr(implode(' ', $classes)) . '"'];
-		$attrs[] = 'aria-hidden="true"';
-
-
-
-		return '<i ' . implode(' ', $attrs) . '></i>';
-	}
-
-	/**
-	 * Track icon usage for CSS generation
-	 */
-	protected function trackIconUsage(string $name, string $style, string $source = 'icons'): void
-	{
-		// Initialize source array if needed
-		if (!isset($this->usedIcons[$source])) {
-			$this->usedIcons[$source] = [];
-		}
-
-		// Initialize style array if needed
-		if (!isset($this->usedIcons[$source][$style])) {
-			$this->usedIcons[$source][$style] = [];
-		}
-
-		// Add icon if not already tracked
-		if (!in_array($name, $this->usedIcons[$source][$style])) {
-			$this->usedIcons[$source][$style][] = $name;
-			$needsUpdate = true;
-		}
-
-		if ($needsUpdate) {
-			$existing = get_option(BASE.'usedIcons', []);
-			$merged = $this->mergeUsedIcons($existing, $this->usedIcons);
-			update_option(BASE.'usedIcons', $merged);
-			update_option(BASE.'icons_needs_update', true);
-			$this->cache->delete('icon_styles_css');
-		}
-	}
-
-	/**
-	 * Check if icon file exists
-	 */
-	protected function iconExists(string $name, ?string $style = null): bool
-	{
-		if (!$style) {
-			$style = $this->style;
-		}
-		// Check custom icons first
-		if (array_key_exists($name, $this->customIcons)) {
-			return file_exists($this->customIcons[$name]);
-		}
-
-		// Check standard icons
-		$filepath = $this->buildFilePath($name, $style);
-		return file_exists($filepath);
-	}
-
-	/**
-	 * Build file path for icon
-	 */
-	protected function buildFilePath(string $name, ?string $style = null): string
-	{
-		if (!$style) {
-			$style = $this->style;
-		}
-		// Custom icons (absolute path provided)
-		if (array_key_exists($name, $this->customIcons)) {
-			return $this->customIcons[$name];
-		}
-
-		// Standard SVG icons in /assets/icons/
-		if (str_ends_with($name, '.svg')) {
-			return JVB_DIR . '/assets/icons/' . $name;
-		}
-		$name = ($style === 'regular') ? $name : $name . '-' . $style;
-
-		// Phosphor icons with style variants
-		return JVB_DIR . '/assets/phosphor-icons/' . $style . '/' . $name . '.svg';
-	}
-
-	/**
-	 * Get raw SVG content for CSS mask-image
-	 */
-	protected function getRawSvg(string $name, ?string $style = null): ?string
-	{
-		if (!$style) {
-			$style = $this->style;
-		}
-		$filepath = $this->buildFilePath($name, $style);
-
-		if (!file_exists($filepath)) {
-			return null;
-		}
-
-		$svg = file_get_contents($filepath);
-		if ($svg === false) {
-			return null;
-		}
-
-		// Clean up SVG for CSS usage
-		$svg = preg_replace("/([\n\t]+)/", ' ', $svg);
-		$svg = preg_replace('/>\s*</', '><', $svg);
-		$svg = trim($svg);
-
-		return $svg;
-	}
-
-
-	/**
-	 * Enqueue icon styles via REST endpoint
-	 */
-	public function enqueueIconStyles(): void
-	{
-		$timestamp = CacheManager::getTimestamp('icons');
-
-		wp_enqueue_style(
-			'jvb-icons',
-			JVB_CHILD_URL.'assets/css/icons.css',
-			[],
-			$timestamp
-		);
-	}
-
-	/**
-	 * Generate CSS from icon list
-	 */
-	protected function generateIconCSS(string $source = 'icons'): string
-	{
-		$css = '';
-
-		if (!isset($this->usedIcons[$source])) {
-			return $css;
-		}
-
-		foreach ($this->usedIcons[$source] as $style => $icons) {
-			$styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : '';
-			foreach ($icons as $icon) {
-				$svg = $this->getEncodedSVG($icon, $style);
-				if ($svg !== '') {
-					$css .= ".icon-{$icon}{$styleClass}{";
-					$css .= "--icon:url('data:image/svg+xml;base64,{$svg}');";
-					$css .= "}";
-				}
-			}
-		}
-		return $this->minifyCss($css);
-	}
-
-	protected function mergeUsedIcons(array|bool $oldIcons = true, array|bool $newIcons = true):array
-	{
-		$set = false;
-		if ($oldIcons === true) {
-			$oldIcons = $this->usedIcons;
-			$set = true;
-		}
-		if ($newIcons === true) {
-			$history = $this->getVersionHistory();
-			$newIcons = (count($history) > 0) ? $history[0]['iconList'] : [];
-		}
-		foreach ($newIcons as $style => $icons) {
-			if (!isset($oldIcons[$style])) {
-				//Style  doesn't exist in previous set, add the whole thing
-				$oldIcons[$style] = $icons;
-			} else {
-				$oldIcons[$style] = array_unique(
-					array_merge($oldIcons[$style], $icons)
-				);
-			}
-		}
-		if ($set) {
-			$this->usedIcons = $oldIcons;
-			update_option(BASE.'usedIcons', $oldIcons);
-		}
-		return $oldIcons;
-	}
-
-	protected function minifyCSS(string $css): string
-	{
-		// Remove comments
-		$css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css);
-		// Remove whitespace
-		$css = preg_replace('/\s+/', ' ', $css);
-		// Remove spaces around specific characters
-		$css = preg_replace('/\s*([:;{}])\s*/', '$1', $css);
-
-		return trim($css);
-	}
-	public function getCSSIcon(string $icon, ?string $style=null):string
-	{
-		if (!$style) {
-			$style = $this->style;
-		}
-		$svg = $this->getEncodedSVG($icon, $style);
-		if ($svg !== '') {
-			return "data:image/svg+xml;base64,{$svg}";
-		}
-		return '';
-	}
-	public function getEncodedSVG(string $icon, ?string $style = null):string
-	{
-		if (!$style) {
-			$style = $this->style;
-		}
-		return $this->cache->remember($style.$icon,
-		function () use ($icon, $style) {
-			$svg = $this->getRawSvg($icon, $style);
-			if ($svg) {
-				return base64_encode($svg);
-			}
-			return '';
-		});
-
-	}
-
-	/**
-	 * Clear icon cache (useful for development/debugging)
-	 */
-	public function clearIconCache(): void
-	{
-		delete_option(BASE . 'icon_usage_list'); // Clear DB option
-		delete_option(BASE.'usedIcons');
-		delete_option(BASE.'includeIcons');
-		delete_option(BASE.'iconMap');
-		$this->cache->delete('icon_styles_css');
-		CacheManager::updateTimestamp('icons');
-	}
-
-	protected function archiveCurrentVersion(string $css, string $source = 'icons'): void
-	{
-		$history = $this->getVersionHistory($source);
-
-		$icon_count = 0;
-		if (isset($this->usedIcons[$source])) {
-			foreach ($this->usedIcons[$source] as $style => $icons) {
-				$icon_count += count($icons);
-			}
-		}
-
-		$newEntry = [
-			'css' => $css,
-			'iconList' => $this->usedIcons[$source] ?? [],
-			'timestamp' => time(),
-			'icon_count' => $icon_count,
-			'size' => strlen($css),
-			'size_formatted' => size_format(strlen($css), 2)
-		];
-
-		array_unshift($history, $newEntry);
-
-		if (count($history) > self::MAX_VERSIONS) {
-			$history = array_slice($history, 0, self::MAX_VERSIONS);
-		}
-
-		update_option(BASE.'icon_css_history_' . $source, $history);
-	}
-
-	public function getVersionHistory(string $source = 'icons'): array
-	{
-		return get_option(BASE.'icon_css_history_' . $source, []);
-	}
-
-
-	public function restoreVersion(int $timestamp): bool
-	{
-		$history = $this->getVersionHistory();
-
-		foreach ($history as $entry) {
-			if ($entry['timestamp'] === $timestamp) {
-				$css_path = JVB_DIR . '/assets/css/icons.css';
-
-				// Archive current before restoring
-				$current_css = file_get_contents($css_path);
-				if ($current_css !== false) {
-					$this->archiveCurrentVersion($current_css);
-				}
-
-				// Restore the version
-				if (file_put_contents($css_path, $entry['css']) !== false) {
-					$this->usedIcons = $entry['iconList'];
-					update_option(BASE.'usedIcons', $this->usedIcons);
-					CacheManager::updateTimestamp('icons');
-					return true;
-				}
-
-				return false;
-			}
-		}
-
-		error_log("[IconsManager] Version {$timestamp} not found in history");
-		return false;
-	}
-
-	public function forceRefresh(): void
-	{
-		$this->clearIconCache();
-		update_option(BASE.'icons_needs_update', true);
-		CacheManager::updateTimestamp('icons');
-	}
-
-	public function mergeVersions(array $timestamps): bool
-	{
-		if (empty($timestamps)) {
-			return false;
-		}
-
-		$history = get_option(BASE.'icon_css_history', []);
-		$merged_icons = [];
-		// Collect icons from selected versions
-		foreach ($history as $entry) {
-			if (in_array($entry['timestamp'], $timestamps)) {
-				foreach ($entry['iconList'] as $style => $icons) {
-					if (!isset($merged_icons[$style])) {
-						$merged_icons[$style] = [];
-					}
-					// Merge and keep unique
-					$merged_icons[$style] = array_unique(
-						array_merge($merged_icons[$style], $icons)
-					);
-				}
-			}
-		}
-
-		if (empty($merged_icons)) {
-			error_log('[IconsManager] No icons found in selected versions');
-			return false;
-		}
-
-		// Archive current version
-		$current_css = file_get_contents(JVB_DIR . '/assets/css/icons.css');
-		if ($current_css !== false) {
-			$this->archiveCurrentVersion($current_css);
-		}
-
-		// Update used icons and regenerate
-		$this->usedIcons = $merged_icons;
-		update_option(BASE.'usedIcons', $this->usedIcons);
-
-		// Force regeneration
-		$this->regenerateCSS();
-
-		return true;
-	}
-}
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 308a51c..5a25159 100644
--- a/inc/managers/LoginManager.php
+++ b/inc/managers/LoginManager.php
@@ -18,7 +18,7 @@
 {
 	protected Features $siteFeatures;
 	protected ?MetaForm $metaForm = null;
-	protected CacheManager $cache;
+	protected Cache $cache;
 
 
 	protected array $forms =[];
@@ -43,7 +43,7 @@
 		$this->siteFeatures = Features::forSite();
 
 
-		$this->cache = CacheManager::for('login');
+		$this->cache = Cache::for('login');
 
 		// Initialize magic link support if enabled
 		if ($this->siteFeatures->has('magicLink')) {
diff --git a/inc/managers/LoginManagerOld.php b/inc/managers/LoginManagerOld.php
deleted file mode 100644
index a2fa1b7..0000000
--- a/inc/managers/LoginManagerOld.php
+++ /dev/null
@@ -1,1061 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\meta\MetaManager;
-use JVBase\utility\Features;
-use WP_Error;
-use WP_User;
-
-if (!defined('ABSPATH')) {
-	exit; // Exit if accessed directly
-}
-
-class LoginManagerOld
-{
-	protected ?MagicLinkManager $magicLink = null;
-	private array|null $invitation_data = null;
-	protected array $inviteData = [];
-	private array $allowed_file_types = [
-		'image/jpeg',
-		'image/png',
-		'image/gif',
-		'application/pdf'
-	];
-	private int $max_file_size = 5242880; // 5MB in bytes
-
-	public function __construct()
-	{
-		// Common login page customization
-		add_action('login_enqueue_scripts', [$this, 'loginStyles']);
-		add_action('login_header', [$this, 'loginHeader'], 0);
-		add_action('login_footer', [$this, 'loginFooter']);
-
-		// Login page filters
-		add_filter('login_headerurl', [$this, 'logoUrl']);
-		add_filter('login_headertext', [$this, 'logoTitle']);
-		add_filter('login_message', [$this, 'loginMessage']);
-		add_filter('login_errors', [$this, 'loginErrors']);
-
-		// Login success handling
-		add_action('wp_login', [$this, 'handleSuccessfulLogin'], 10, 2);
-
-		if (Features::forSite()->has('magicLink')) {
-			$this->magicLink = new MagicLinkManager();
-		}
-
-		// Registration-specific hooks
-		if ($this->isRegistrationPage()) {
-			$this->initRegistrationHooks();
-		}
-	}
-
-	/**
-	 * Check if we're on the registration page
-	 */
-	private function isRegistrationPage(): bool
-	{
-		return isset($_GET['action']) && $_GET['action'] === 'register';
-	}
-
-	/**
-	 * Initialize registration-specific hooks
-	 */
-	private function initRegistrationHooks(): void
-	{
-		add_action('register_form', [$this, 'addRegistrationFields']);
-		add_action('login_header', [$this, 'addRegistrationScript']);
-		add_filter('registration_errors', [$this, 'registrationErrorsFilter'], 10, 3);
-		add_action('user_register', [$this, 'saveRegistrationFields'], 999, 2);
-		add_action('login_head', [$this, 'modifyRegistrationForm']);
-		add_action('register_form', [$this, 'addUploadSupport']);
-		add_filter('pre_user_login', [$this, 'setUserLogin'], 1);
-		add_filter('pre_user_email', [$this, 'setUserEmail'], 1);
-		add_filter('register_message', [$this, 'customRegisterMessage']);
-		add_filter('wp_login_errors', [$this, 'registrationSuccessMessage'], 10, 2);
-		add_filter('login_form_top', [$this, 'loginFormTop']);
-		add_filter('login_form_bottom', [$this, 'loginFormBottom']);
-		add_filter('login_form_middle', [$this, 'loginFormMiddle']);
-
-		// Remove default username requirement for registration
-		remove_filter('registration_errors', 'registration_auth_pass_filter', 10);
-	}
-
-	/**
-	 * Combined login styles for both login and registration
-	 */
-	public function loginStyles(): void
-	{
-		do_action('jvbLoginStyles');
-	}
-
-	/**
-	 * Login header - used for both login and registration
-	 */
-	public function loginHeader(): void
-	{
-		?>
-		<script type="text/javascript">
-			document.addEventListener('DOMContentLoaded', function() {
-				let loginLabel = document.querySelector('label[for="user_login"');
-				loginLabel.innerHTML = '<?= jvbIcon('envelope', ['size' => 20]); ?> Your Email';
-
-				let passwordLabel = document.querySelector('label[for="user_pass"');
-				passwordLabel.innerHTML = '<?= jvbIcon('password', ['size' => 20]); ?> Your Password';
-
-				document.querySelector('form').classList.add('loaded');
-			});
-
-		</script>
-		<?php
-	}
-
-	/**
-	 * Login footer with donate section
-	 */
-	public function loginFooter(): void
-	{
-		do_action('jvbLoginFooter');
-
-	}
-
-	/**
-	 * Logo URL
-	 */
-	public function logoUrl(): string
-	{
-		return home_url();
-	}
-
-	/**
-	 * Logo title
-	 */
-	public function logoTitle(): string
-	{
-		return get_bloginfo('name');
-	}
-
-	/**
-	 * Login message - handles both login and registration
-	 */
-	public function loginMessage(string $message): string
-	{
-		if ($this->isRegistrationPage()) {
-			if (jvbSiteHasInvitations() && $this->fromInvite()) {
-				$data = JVB()->routes('invites')->verifyInvitation(sanitize_text_field($_GET['invite']), sanitize_email($_GET['email']));
-				$name = $data->name;
-				$inviters = json_decode($data->inviters, true);
-				$names = [];
-				foreach ($inviters as $inviter) {
-					$artist = jvbContentFromUser((int)$inviter['user_id']);
-					$names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
-				}
-				$message = (count($names) > 1) ? 'are already here, and have invited you to join in!' : ' is already here, and invited you to join in!';
-				return '<h2>Join the Scene, '.$name.'</h2>
-                <p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
-			}
-			if (jvbSiteHasFavourites() && $this->fromFavourites()) {
-				return '<h2>'.JVB_LOGIN['login_from_favourite_header']??'Save your Favourites'.'</h2>';
-			}
-			return '<h2>'.JVB_LOGIN['join_header'].'</h2>';
-		} else {
-			if (jvbSiteHasFavourites()) {
-				$login = (!$this->fromFavourites()) ? '<h2>'.JVB_LOGIN['login_header'].'</h2>' : '<h2>'.JVB_LOGIN['login_from_favourite_header'].'</h2>';
-			} else {
-				$login = '<h2>'.JVB_LOGIN['login_header'].'</h2>';
-			}
-
-			return (empty($message)) ? $login : $login.$message;
-		}
-	}
-
-	protected function fromFavourites():bool
-	{
-		return array_key_exists('type', $_GET) && $_GET['type'] === 'favourites';
-	}
-
-	/**
-	 * Customize login error messages
-	 */
-	public function loginErrors(string $error): string
-	{
-		return str_replace(
-			[
-				'The password you entered for the username',
-				'Invalid username',
-				'Unknown username',
-				'Unknown email address'
-			],
-			[
-				'Wrong password',
-				'We can\'t find that username',
-				'We can\'t find that username',
-				'We can\'t find that email'
-			],
-			$error
-		);
-	}
-
-	/**
-	 * Handle successful login
-	 */
-	public function handleSuccessfulLogin(string $username, WP_User $user): void
-	{
-		if (isOurPeople() && !user_can($user, 'manage_options')) {
-			wp_redirect(get_home_url(null, '/dash'));
-			exit;
-		}
-	}
-
-	// ===== REGISTRATION-SPECIFIC METHODS =====
-
-	/**
-	 * Set user login for registration
-	 */
-	public function setUserLogin(string $login): string
-	{
-		$user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-		if (!empty($user_type)) {
-			$email_field = $user_type . '_email';
-			if (isset($_POST[$email_field])) {
-				$email = sanitize_email($_POST[$email_field]);
-				if (is_email($email)) {
-					return $email;
-				}
-			}
-		}
-		return $login;
-	}
-
-	/**
-	 * Set user email for registration
-	 */
-	public function setUserEmail(string $email): string
-	{
-		$user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-		if (!empty($user_type)) {
-			$email_field = $user_type . '_email';
-			if (isset($_POST[$email_field])) {
-				$email = sanitize_email($_POST[$email_field]);
-				if (is_email($email)) {
-					return $email;
-				}
-			}
-		}
-		return $email;
-	}
-
-	/**
-	 * Modify registration form
-	 */
-	public function modifyRegistrationForm(): void
-	{
-		if (!$this->isRegistrationPage()) {
-			return;
-		}
-
-		?>
-		<script type="text/javascript">
-			document.addEventListener('DOMContentLoaded', function() {
-				// Hide default fields
-				const defaultFields = document.getElementById('registerform').querySelectorAll('p');
-				defaultFields.forEach(field => {
-					if (field.querySelector('label[for="user_login"]') ||
-						field.querySelector('label[for="user_email"]')) {
-						field.remove();
-					}
-				});
-
-				// Hide the default registration info text
-				const regInfo = document.querySelector('.message.register');
-				if (regInfo) {
-					regInfo.style.display = 'none';
-				}
-
-				<?php
-				if ($this->fromInvite()) {
-					$this->handleArtistInvitation();
-				}
-				?>
-
-				// Move submit button to the end of the form
-				const submitButton = document.getElementById('registerform').querySelector('.submit');
-				if (submitButton) {
-					document.getElementById('registerform').appendChild(submitButton);
-				}
-			});
-		</script>
-		<?php
-	}
-
-	/**
-	 * Handle artist invitation pre-fill
-	 */
-	protected function handleArtistInvitation(): void
-	{
-		$token = sanitize_text_field($_GET['invite']);
-		$email = sanitize_email($_GET['email']);
-		$data = JVB()->routes('invites')->verifyInvitation($token, $email);
-
-		?>
-		document.querySelector('input#artist').checked = true;
-		document.querySelector('#artist_first_name').value = '<?=$data->name?>';
-		document.querySelector('#artist_email').value = '<?=$email?>';
-		<?php
-		if ($data->to_shop) {
-			?>
-			document.querySelector('#artist_shop').value = '<?=$data->shop?>';
-			<?php
-		}
-		?>
-		let form = document.getElementById('registerform')
-		let input = document.createElement('input');
-		let email = input.cloneNode(true);
-		input.type = 'hidden';
-		input.name = 'invite_token';
-		input.value = '<?= $token ?>';
-		email.type = 'hidden';
-		email.name = 'invite_email';
-		email.value = '<?= $email?>';
-		form.append(input);
-		form.append(email);
-		<?php
-	}
-
-	/**
-	 * Add upload support for registration
-	 */
-	public function addUploadSupport(): void
-	{
-		?>
-		<script>
-			document.addEventListener('DOMContentLoaded', function() {
-				const form = document.getElementById('registerform');
-				if (form) {
-					form.enctype = 'multipart/form-data';
-				}
-			});
-		</script>
-		<?php
-	}
-
-	/**
-	 * Add registration script
-	 */
-	public function addRegistrationScript(): void
-	{
-		if (!$this->isRegistrationPage()) {
-			return;
-		}
-		?>
-		<script>
-			document.addEventListener('DOMContentLoaded', function() {
-
-				// Initialize user type selection
-				function initUserTypeSelection() {
-					const userTypeRadios = document.querySelectorAll('input[name="user_type"]');
-					const fieldGroups = document.querySelectorAll('.field-group');
-
-					userTypeRadios.forEach(radio => {
-						radio.addEventListener('change', function() {
-							fieldGroups.forEach(group => group.classList.remove('active'));
-							const selectedType = this.value;
-							const targetGroup = document.querySelector(`.field-group[data-type="${selectedType}"]`);
-							if (targetGroup) {
-								targetGroup.classList.add('active');
-							}
-						});
-					});
-
-					const checkedRadio = document.querySelector('input[name="user_type"]:checked');
-					if (checkedRadio) {
-						const targetGroup = document.querySelector(`.field-group[data-type="${checkedRadio.value}"]`);
-						if (targetGroup) {
-							targetGroup.classList.add('active');
-						}
-					}
-				}
-
-				// Initialize shop selection
-				function initShopSelection() {
-					let form = document.getElementById('registerform');
-					form.addEventListener('change', (e) => {
-						if(e.target.id === 'artist_shop' || e.target.id === 'artist_city'){
-							let next = e.target.parentNode.nextElementSibling;
-							let input = next.querySelector('input');
-
-							if(e.target.value === 'other'){
-								next.style.display = 'block';
-								next.style.animation = 'fadeIn 0.3s ease';
-								input.required = true;
-								input.focus();
-							}else{
-								input.required = false;
-								input.value = '';
-							}
-						}
-					});
-				}
-
-				// Initialize file upload handling
-				function initFileUpload() {
-					const fileInput = document.getElementById('certification_file');
-					const filePreview = document.querySelector('.file-preview');
-					const filePreviewName = document.querySelector('.file-preview-name');
-					const fileError = document.querySelector('.file-error');
-					const removeButton = document.querySelector('.file-preview-remove');
-
-					if (!fileInput || !filePreview || !filePreviewName || !fileError || !removeButton) {
-						return;
-					}
-
-					const maxSize = parseInt(fileInput.dataset.maxSize || 5242880);
-
-					fileInput.addEventListener('change', function(e) {
-						const file = e.target.files[0];
-						fileError.classList.remove('active');
-
-						if (file) {
-							const validTypes = ['.jpg','.jpeg','.png','.gif','.pdf'];
-							const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
-
-							if (!validTypes.includes(fileExtension)) {
-								showError('Please upload a valid file type (JPG, PNG, GIF, or PDF)');
-								fileInput.value = '';
-								return;
-							}
-
-							if (file.size > maxSize) {
-								showError('File size must be less than 5MB');
-								fileInput.value = '';
-								return;
-							}
-
-							filePreviewName.textContent = file.name;
-							filePreview.classList.add('active');
-						} else {
-							filePreview.classList.remove('active');
-						}
-					});
-
-					removeButton.addEventListener('click', function() {
-						fileInput.value = '';
-						filePreview.classList.remove('active');
-						fileError.classList.remove('active');
-					});
-
-					function showError(message) {
-						fileError.textContent = message;
-						fileError.classList.add('active');
-						filePreview.classList.remove('active');
-					}
-				}
-
-				// Initialize all components
-				initUserTypeSelection();
-				initShopSelection();
-				initFileUpload();
-			});
-		</script>
-		<?php
-	}
-
-	/**
-	 * Add registration fields
-	 */
-	public function addRegistrationFields(): void
-	{
-		echo '<input type="hidden" name="user_pass" value="' . wp_generate_password() . '">';
-		?>
-		<div class="registration-intro">
-			<?php
-			foreach (JVB_LOGIN['join_intro']??[] as $intro) {
-				echo '<p>'.$intro.'</p>';
-			}
-			?>
-
-			<?php if ($this->fromFavourites()): ?>
-				<div class="favourites-login-message">
-					<ul class="benefits-list">
-						<?php
-						foreach (JVB_LOGIN['from_favourites_benefits']??[] as $benefit) {
-							echo '<li>'.$benefit.'</li>';
-						}
-						?>
-					</ul>
-				</div>
-			<?php endif; ?>
-		</div>
-
-		<?php
-		if (array_key_exists('choose', JVB_LOGIN)) {
-			?>
-			<h3><?= JVB_LOGIN['choose']?></h3>
-			<?php
-		}
-		?>
-
-		<?php
-		if (count(JVB_USER) > 1) {
-			$this->renderUserTypeSelection();
-		} else {
-			?>
-			<p>
-				<label for="first_name" class="required-field">First Name</label>
-				<input type="text" id="first_name" name="first_name" class="input">
-			</p>
-			<p>
-				<label for="email" class="required-field">Email</label>
-				<input type="email" id="email" name="email" class="input">
-			</p>
-			<?php
-		}
-		if ($this->invitation_data) {
-			?>
-			<script>
-				document.addEventListener('DOMContentLoaded', function() {
-					const artistRadio = document.getElementById('artist');
-					if (artistRadio) {
-						artistRadio.checked = true;
-						artistRadio.dispatchEvent(new Event('change'));
-					}
-
-					const emailField = document.getElementById('artist_email');
-					if (emailField) {
-						emailField.value = '<?= esc_js($this->invitation_data['email']); ?>';
-						emailField.readOnly = true;
-					}
-
-					const shopSelect = document.getElementById('artist_shop');
-					if (shopSelect) {
-						shopSelect.value = '<?= esc_js($this->invitation_data['shop_id']); ?>';
-						shopSelect.readOnly = true;
-					}
-				});
-			</script>
-			<input type="hidden" name="invitation_token" value="<?= sanitize_text_field($_GET['invite']) ?>">
-			<input type="hidden" name="invitation_email" value="<?= sanitize_email($_GET['email']) ?>">
-			<?php
-		}
-	}
-
-	protected function renderUserTypeSelection():void
-	{
-
-
-		// Get list of tattoo shops and cities
-		$shops = get_terms([
-			'taxonomy' => 'jvb_shop',
-			'hide_empty' => true
-		]);
-
-		$cities = get_terms([
-			'taxonomy' => 'jvb_city',
-			'hide_empty' => false,
-		]);
-		?>
-		<div class="user-type-section">
-
-			<?php
-			$i = 1;
-			$radio = '<input type="radio" id="user0" name="user_type" value="subscriber" required checked>
-            <label for="user0"></label>';
-			$descriptions = '';
-			foreach (JVB_USER as $role => $config) {
-				if (jvbCheck('can_register', $config)) {
-					$radio .= '<input type="radio" id="user'.$i.'" name="user_type" value="'.$role.'" required';
-					$radio .= ($role === 'enthusiast' && $this->fromFavourites()) ? 'checked' : '';
-					$radio .= '><label for="user'.$i.'">'.jvbIcon($role, ['title' =>$config['label'], 'size'=>40]).'<h4>'.$config['label'].'</h4><p>';
-					$radio .=  $config['join_text']??'';
-					$radio .= '</p></label>';
-
-					$descriptions .= '<div class="user'.$i.'">'.is_array($config['join_description']) ? implode('', array_map(function ($item) { return '<p>'.$item.'</p>'; }, $config['join_description'])) : '<p>'.$config['join_description'].'</p>'.'</div>';
-
-					$i++;
-				}
-			}
-
-			echo $radio;
-			echo $descriptions;
-			?>
-			<input type="radio" id="enthusiast" name="user_type" value="enthusiast" required <?= ($this->fromFavourites()) ? 'checked' : '' ?>>
-			<label for="enthusiast"><?=jvbIcon('heart', ['title' =>'Enthusiast', 'size'=>40])?><h4>Enthusiast</h4><p>Start here.</p></label>
-			<input type="radio" id="artist" name="user_type" value="artist" required>
-			<label for="artist"><?=jvbIcon('drop-simple', ['title'=> 'Artist', 'size'=> 40])?><h4>Artist</h4><p>Show your talent.</p></label>
-			<input type="radio" id="partner" name="user_type" value="partner" required>
-			<label for="partner"><?=jvbIcon('currency-circle-dollar', ['title'=>'Partner', 'size' => 40])?><h4>Partner</h4><p>Support the community.</p></label>
-			<p class="enthusiast">Save your favourites. Get notified.</p>
-			<p class="artist">Show off your work.</p>
-			<p class="partner">Support the community.</p>
-		</div>
-
-		<!-- Enthusiast Fields -->
-		<div class="field-group" data-type="enthusiast">
-			<h4>Welcome to the scene.</h4>
-			<p>Sign up with your email to:</p>
-			<ul>
-				<li>Save your favourites for easy access</li>
-				<li>Get notified when your favourite artists add new content</li>
-				<li>Stay in the loop with local flash days and events</li>
-				<li>Discover styles and artists that match your vision</li>
-			</ul>
-			<p>
-				<label for="enthusiast_first_name" class="required-field">First Name</label>
-				<input type="text" id="enthusiast_first_name" name="enthusiast_first_name" class="input">
-			</p>
-			<p>
-				<label for="enthusiast_email" class="required-field">Email</label>
-				<input type="email" id="enthusiast_email" name="enthusiast_email" class="input">
-			</p>
-			<div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
-		</div>
-
-		<!-- Artist Fields -->
-		<div class="field-group" data-type="artist">
-			<h4>Welcome to the scene!</h4>
-			<p>We'll start small, with the basics. Before your profile goes live, we need to verify:</p>
-			<ul>
-				<li>you are who you say you are</li>
-				<li>you work at the shop you listed</li>
-				<li>your certification</li>
-			</ul>
-			<p>
-				<label for="artist_first_name" class="required-field">First Name</label>
-				<input type="text" id="artist_first_name" name="artist_first_name" class="input">
-			</p>
-			<p>
-				<label for="artist_last_name" class="required-field">Last Name</label>
-				<input type="text" id="artist_last_name" name="artist_last_name" class="input">
-			</p>
-			<p>
-				<label for="artist_email" class="required-field">Email</label>
-				<input type="email" id="artist_email" name="artist_email" class="input">
-			</p>
-			<p>
-				<label for="artist_shop" class="required-field">Shop</label>
-				<select id="artist_shop" name="artist_shop" class="input">
-					<option value="">Select a shop</option>
-					<option value="other">Add New Shop</option>
-					<?php foreach ($shops as $shop) : ?>
-						<option value="<?= esc_attr($shop->term_id); ?>"><?= esc_html($shop->name); ?></option>
-					<?php endforeach; ?>
-				</select>
-			</p>
-			<p id="other_shop_field" style="display: none;">
-				<label for="artist_shop_other" class="required-field">Shop Name</label>
-				<input type="text" id="artist_shop_other" name="artist_shop_other" class="input" placeholder="Shop name">
-			</p>
-
-			<p>
-				<label for="artist_type" class="required-field">Type</label>
-				<input type="radio" id="type-tattoo-artist" name="artist_type" value="tattoo-artist">
-				<label for="type-tattoo-artist">Tattoo Artist</label>
-				<input type="radio" id="type-piercer" name="artist_type" value="piercer">
-				<label for="type-piercer">Piercer</label>
-				<input type="radio" id="type-other" name="artist_type" value="other">
-				<label for="type-other">Other</label>
-			</p>
-			<p>
-				<label for="artist_city" class="required-field">City</label>
-				<select id="artist_city" name="artist_city" class="input">
-					<option value="">Select a city</option>
-					<option value="other">Add New City</option>
-					<?php foreach ($cities as $city) : ?>
-						<option value="<?= esc_attr($city->term_id); ?>"><?= esc_html($city->name); ?></option>
-					<?php endforeach; ?>
-				</select>
-			</p>
-			<p id="other_city_field" style="display: none;">
-				<label for="artist_city_other" class="required-field">City Name</label>
-				<input type="text" id="artist_city_other" name="artist_city_other" class="input" placeholder="City">
-			</p>
-
-			<div class="file-upload-container">
-				<label class="file-upload-label">Certification or Training Documents</label>
-				<p><i>Optional</i> — If you've been certified in bloodborne pathogen safety, or any other tattoo safety course, pass along your certificate. This just eases the verification process.</p>
-				<div class="file-upload-wrapper">
-					<input type="file" name="certification_file" id="certification_file" accept=".jpg,.jpeg,.png,.gif,.pdf" data-max-size="<?= $this->max_file_size; ?>">
-					<p class="file-upload-text">
-						<strong>Click to upload</strong> or drag and drop<br>
-						JPG, PNG, GIF or PDF (max. 5MB)
-					</p>
-				</div>
-				<div class="file-preview">
-					<div class="file-preview-content">
-						<span class="file-preview-name"></span>
-						<button type="button" class="file-preview-remove">Remove</button>
-					</div>
-				</div>
-				<div class="file-error"></div>
-			</div>
-			<p>Once you click register:</p>
-			<ul>
-				<li>We'll start looking into your information (usually within 24-48 hours)</li>
-				<li>You'll get a password reset email</li>
-				<li>Upon setting your password, you can start filling in your profile - but it won't go live until we've verified your information.</li>
-			</ul>
-			<p>If you have any questions or concerns - or anything you'd like to follow up on - email us at get@edmonton.ink or message us on <a target="_blank" href="https://www.instagram.com/edmonton.ink/" title="@edmonton.ink on Instagram">Instagram</a>.</p>
-			<div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
-		</div>
-
-		<!-- Partner Fields -->
-		<div class="field-group" data-type="partner">
-			<h4>Howdy, partner!</h4>
-			<p>We appreciate your interest!</p>
-			<p>edmonton.ink is a great place to showcase what you do, whether you:</p>
-			<ul>
-				<li>provide goods or services that tattoo artists could use</li>
-				<li>provide goods or services that are tattoo adjacent (such as art, merch, etc)</li>
-				<li>provide goods or services that folks who love tattoos could also love</li>
-			</ul>
-
-			<p>We'll start with some basics, then we'll reach out to follow up (usually within 24-48 hours).</p>
-			<p>
-				<label for="partner_name" class="required-field">Contact Name</label>
-				<input type="text" id="partner_name" name="partner_name" class="input">
-			</p>
-			<p>
-				<label for="partner_email" class="required-field">Email</label>
-				<input type="email" id="partner_email" name="partner_email" class="input">
-			</p>
-			<p>
-				<label for="partner_business" class="required-field">Business Name</label>
-				<input type="text" id="partner_business" name="partner_business" class="input">
-			</p>
-			<p>
-				<label for="partner_website">Business Website</label>
-				<input type="url" id="partner_website" name="partner_website" class="input">
-			</p>
-			<p>
-				<label for="partner_description">Why would you be a good fit?</label>
-				<textarea id="partner_description" name="partner_description" rows="8"></textarea>
-			</p>
-			<p><i>Note:</i> — you must have good standing in the tattoo community to stay a partner of edmonton.ink.</p>
-			<p>If we receive multiple requests to terminate a partnership with you from member artists, we reserve the right to cancel your listings.</p>
-		</div>
-		<?php
-	}
-
-	/**
-	 * Registration errors filter
-	 */
-	public function registrationErrorsFilter(WP_Error $errors, string $sanitized_user_login, string $user_email): WP_Error
-	{
-		error_log('Registration Data: '.print_r($_POST, true));
-		$user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-
-		if (empty($user_type)) {
-			$errors->add('user_type_error', 'Please select your user type.');
-			return $errors;
-		}
-
-		// Get email based on user type
-		$email_field = $user_type . '_email';
-		$email = isset($_POST[$email_field]) ? sanitize_email($_POST[$email_field]) : '';
-
-		// Remove WordPress's default username error
-		$errors = new WP_Error();
-
-		// If this is an invited artist, validate the invitation
-		$invite = (array_key_exists('invite_token', $_POST)) ? sanitize_text_field($_POST['invite_token']) : false;
-		if ($invite && array_key_exists('role', $_POST)) {
-			$handler = JVB()->routes('invites');
-			$invitation = $handler->verifyInvitation($invite, sanitize_email($_POST['invite_email']), sanitize_text_field($_POST['role']));
-
-			if (!$invitation) {
-				$errors->add('invalid_invitation', 'Invalid invitation token.');
-			} elseif (strtotime($invitation->expires_at) < current_time('timestamp')) {
-				$errors->add('expired_invitation', 'This invitation has expired.');
-			}
-		}
-
-		// Validate email first
-		if (empty($email)) {
-			$errors->add('email_error', 'Email is required.');
-		} elseif (!is_email($email)) {
-			$errors->add('email_error', 'Please enter a valid email address.');
-		} elseif (email_exists($email)) {
-			$errors->add('email_error', 'This email is already registered.');
-		}
-
-		switch ($user_type) {
-			case 'enthusiast':
-				if (empty($_POST['enthusiast_first_name'])) {
-					$errors->add('first_name_error', 'First name is required.');
-				}
-				break;
-
-			case 'artist':
-				$required_fields = [
-					'artist_first_name' => 'First name',
-					'artist_last_name' => 'Last name',
-					'artist_shop' => 'Shop',
-					'artist_city' => 'City',
-					'artist_type' => 'Type',
-				];
-				foreach ($required_fields as $field => $label) {
-					if (empty($_POST[$field])) {
-						$errors->add($field . '_error', $label . ' is required.');
-					}
-				}
-				break;
-
-			case 'partner':
-				$required_fields = [
-					'partner_name' => 'Contact name',
-					'partner_business' => 'Business name'
-				];
-
-				foreach ($required_fields as $field => $label) {
-					if (empty($_POST[$field])) {
-						$errors->add($field . '_error', $label . ' is required.');
-					}
-				}
-				break;
-		}
-
-		if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
-			$file = $_FILES['certification_file'];
-
-			// Validate file type
-			if (!in_array($file['type'], $this->allowed_file_types)) {
-				$errors->add('file_type_error', 'Please upload a valid file type (JPG, PNG, GIF, or PDF)');
-			}
-
-			// Validate file size
-			if ($file['size'] > $this->max_file_size) {
-				$errors->add('file_size_error', 'File size must be less than 5MB');
-			}
-		}
-
-		return $errors;
-	}
-
-	/**
-	 * Save registration fields
-	 */
-	public function saveRegistrationFields(int $user_id, array $userdata): void
-	{
-		$user_type = isset($_POST['user_type']) ? $_POST['user_type'] : false;
-		if (!$user_type) {
-			return;
-		}
-
-		// Set user role based on type
-		$user = new WP_User($user_id);
-		$caps = JVB()->roles();
-		$email = false;
-		$upload_dir = wp_upload_dir();
-		$base_dir = $upload_dir['basedir'];
-
-		switch ($user_type) {
-			case 'artist':
-				$user->set_role('jvb_artist');
-				$user->remove_role('subscriber');
-
-				$email = sanitize_email($_POST['artist_email']);
-				$first = sanitize_text_field($_POST['artist_first_name']);
-				$last = sanitize_text_field($_POST['artist_last_name']);
-				$display_name = $first . ' ' . $last;
-
-				// Save artist fields
-				$temp = wp_update_user([
-					'ID' => $user_id,
-					'first_name' => $first,
-					'last_name' => $last,
-					'display_name' => $display_name
-				]);
-				$user = get_userdata($temp);
-
-				$link = $caps->addUserLink($user, 'artist');
-				$meta = new MetaManager($link, 'post');
-				$meta->setAll([
-					'first_name'	=> $first,
-					'email'			=> $email
-				]);
-
-				// If this was an invited artist, handle the invitation
-				if (array_key_exists('invite_token', $_POST)) {
-					$handler = JVB()->routes('invites');
-					$handler->acceptInvitation(sanitize_text_field($_POST['invite_token']), sanitize_email($_POST['invite_email']), $user->ID);
-				}
-
-				if (absint($_POST['artist_shop']) > 0) {
-					JVB()->routes('shop')->requestShopAdmission($user_id, absint($_POST['artist_shop']));
-				}
-				if (absint($_POST['artist_city']) > 0) {
-					wp_set_post_terms($link, (int)absint($_POST['artist_city']), BASE.'city');
-				}
-
-				//Create approval request and notify verified users
-				JVB()->routes('approvals')->createArtistApprovalRequest($user_id);
-
-				//Make base directories
-				$artist_dir = $base_dir . '/artists/' . $user_id;
-				wp_mkdir_p($artist_dir);
-				wp_mkdir_p($artist_dir . '/artwork');
-				wp_mkdir_p($artist_dir . '/events');
-				wp_mkdir_p($artist_dir . '/profile');
-				wp_mkdir_p($artist_dir . '/temp');
-
-				switch ($_POST['artist_type']) {
-					case 'tattoo-artist':
-						$caps->setUserAs($user, 'tattoo-artist');
-						$term = get_term_by('name', 'Tattoo Artists', BASE.'type');
-						if ($term && !is_wp_error($term)) {
-							wp_set_post_terms($link, $term->term_id, BASE.'type');
-						}
-						wp_mkdir_p($artist_dir . '/tattoos');
-						break;
-					case 'piercer':
-						$caps->setUserAs($user, 'piercer');
-						$term = get_term_by('name', 'Piercers', BASE.'type');
-						if ($term && !is_wp_error($term)) {
-							wp_set_post_terms($link, $term->term_id, BASE.'type');
-						}
-						wp_mkdir_p($artist_dir . '/piercings');
-						break;
-				}
-				break;
-
-			case 'partner':
-				$user->set_role('jvb_partner');
-				$user->remove_role('subscriber');
-				$name = sanitize_text_field($_POST['partner_name']);
-				$email = sanitize_email($_POST['partner_email']);
-
-				$caps->setUserAs($user, 'partner');
-				$link = $caps->addUserLink($user, 'partner');
-
-				// Save partner fields
-				update_user_meta($user_id, 'contact_name', sanitize_text_field($_POST['partner_name']));
-				update_user_meta($user_id, 'business_name', sanitize_text_field($_POST['partner_business']));
-				update_user_meta($user_id, 'business_website', esc_url_raw($_POST['partner_website']));
-
-				// Create partner base directory
-				$partner_dir = $base_dir . '/partners/' . $user_id;
-				wp_mkdir_p($partner_dir);
-				wp_mkdir_p($partner_dir . '/offers');
-				wp_mkdir_p($partner_dir . '/events');
-				wp_mkdir_p($partner_dir . '/profile');
-				wp_mkdir_p($partner_dir . '/temp');
-				break;
-
-			case 'enthusiast':
-				$user->set_role('jvb_enthusiast');
-				$user->remove_role('subscriber');
-				$caps->setUserAs($user, 'enthusiast');
-				$name = sanitize_text_field($_POST['enthusiast_first_name']);
-				$email = sanitize_email($_POST['enthusiast_email']);
-
-				// Save enthusiast fields
-				$temp = wp_update_user([
-					'ID' => $user_id,
-					'first_name' => $name,
-					'user_email' => $email,
-				]);
-				break;
-		}
-
-		// Handle file upload for artists
-		if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
-			$file = $_FILES['certification_file'];
-
-			// Setup upload directory
-			$upload_dir = wp_upload_dir();
-			$user_directory = 'artist-certifications/' . $user_id;
-			$target_dir = $upload_dir['basedir'] . '/' . $user_directory;
-
-			// Create directory if it doesn't exist
-			wp_mkdir_p($target_dir);
-
-			// Generate unique filename
-			$file_extension = pathinfo($file['name'], PATHINFO_EXTENSION);
-			$filename = 'certification-' . time() . '.' . $file_extension;
-			$target_file = $target_dir . '/' . $filename;
-
-			// Move uploaded file
-			if (move_uploaded_file($file['tmp_name'], $target_file)) {
-				// Save file information in user meta
-				update_user_meta($user_id, 'certification_file', [
-					'url' => $upload_dir['baseurl'] . '/' . $user_directory . '/' . $filename,
-					'file' => $target_file,
-					'type' => $file['type'],
-					'original_name' => $file['name']
-				]);
-			}
-		}
-
-		// Handle list invitation acceptance
-		if (isset($_GET['list_token']) && !empty($_GET['list_token']) && isset($_GET['email'])) {
-			$token = sanitize_text_field($_GET['list_token']);
-			$email = sanitize_email($_GET['email']);
-
-			if ($email) {
-				JVB()->routes('favourites')->acceptListInvitation($token, $email, $user_id);
-			}
-		}
-	}
-
-	/**
-	 * Registration success message
-	 */
-	public function registrationSuccessMessage(WP_Error $errors, string $redirect_to): WP_Error
-	{
-		if (isset($errors->errors['registered']) && isset($_POST['invitation_token'])) {
-			// Custom message for invited artists
-			$message = "WELCOME ABOARD!<br><br>" .
-				"Password setup is in your inbox. <br>" .
-				"Since you were invited by a shop, you can skip the verification wait and start building your profile right away! ♡";
-
-			unset($errors->errors['registered']);
-			$errors->add('registered', $message, 'message');
-		}
-
-		if (isset($errors->errors['registered'])) {
-			$user_type = isset($_POST['user_type']) ? $_POST['user_type'] : 'user';
-
-			switch ($user_type) {
-				case 'enthusiast':
-					$message = "YOU'RE IN!<br><br>Check your inbox - we've sent password setup details.<br>Get ready to build your dream artist collection! ♡";
-					break;
-
-				case 'artist':
-					$message = "HELL YEAH!<br><br>Password setup is in your inbox. <br>While we verify your info (24-48hrs), you can start building your profile. <br>Just remember - it stays underground until you're cleared. ♡";
-					break;
-
-				case 'partner':
-					$message = "ROCK ON!<br><br>Check your inbox - we've sent password setup details.<br>We'll check out your pitch in the next 24-48hrs. <br><br>Meanwhile, you can start prepping your presence - but you won't hit the streets until we give the nod. ♡";
-					break;
-
-				default:
-					$message = "YOU'RE ON THE LIST!<br><br>Check your inbox for the next steps. ♡";
-			}
-
-			// Replace the default message
-			unset($errors->errors['registered']);
-			$errors->add('registered', $message, 'message');
-		}
-
-		return $errors;
-	}
-
-	/**
-	 * Check if registration is from invite
-	 */
-	protected function fromInvite(): bool
-	{
-		return isset($_GET['invite']) && isset($_GET['email']);
-	}
-
-	/**
-	 * Custom register message
-	 */
-	public function customRegisterMessage(string $message): string
-	{
-		return "Join Edmonton's tattoo community";
-	}
-}
-
-// Initialize the consolidated auth manager
-//new LoginManager();
-error_log('LoginManager working');
diff --git a/inc/managers/MagicLinkManager.php b/inc/managers/MagicLinkManager.php
index f366c96..c8d0a88 100644
--- a/inc/managers/MagicLinkManager.php
+++ b/inc/managers/MagicLinkManager.php
@@ -16,8 +16,8 @@
  */
 class MagicLinkManager
 {
-	protected CacheManager $cache;
-	protected CacheManager $referral_cache;
+	protected Cache $cache;
+	protected Cache $referral_cache;
 
 	// Token settings
 	protected int $token_expiry = 900; // 15 minutes in seconds
@@ -32,8 +32,8 @@
 
 	public function __construct()
 	{
-		$this->cache = CacheManager::for('magic_links', $this->token_expiry);
-		$this->referral_cache = CacheManager::for('referral_magic_links', 14 * DAY_IN_SECONDS);
+		$this->cache = Cache::for('magic_links', $this->token_expiry);
+		$this->referral_cache = Cache::for('referral_magic_links', 14 * DAY_IN_SECONDS);
 
 		// Hook into WordPress auth flow
 		add_action('template_redirect', [$this, 'handleMagicLinkClick']);
@@ -129,9 +129,9 @@
 		// Delete token after verification (single use)
 		// Check which cache it's in and delete from the correct one
 		if ($token_data['type'] === 'referral') {
-			$this->referral_cache->delete($token);
+			$this->referral_cache->forget($token);
 		} else {
-			$this->cache->delete($token);
+			$this->cache->forget($token);
 		}
 
 		return $token_data;
diff --git a/inc/managers/NewsRelationships.php b/inc/managers/NewsRelationships.php
index 526ea30..12098e9 100644
--- a/inc/managers/NewsRelationships.php
+++ b/inc/managers/NewsRelationships.php
@@ -16,13 +16,13 @@
 class NewsRelationships
 {
     private string $table_name;
-    private CacheManager $cache;
+    private Cache $cache;
 
     public function __construct()
     {
         global $wpdb;
         $this->table_name = $wpdb->prefix . BASE . 'news_relationships';
-        $this->cache = CacheManager::for('news_relationships', WEEK_IN_SECONDS);
+        $this->cache = Cache::for('news_relationships', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true)->connect('user',true);
 
         // Register hooks
         add_action('init', [$this, 'registerHooks']);
@@ -512,7 +512,7 @@
         }
 
         // Update cache
-        $this->cache->delete($shop_id);
+        $this->cache->forget($shop_id);
 
         // Update shop total count
         $this->updateShopTotal($shop_id);
@@ -534,7 +534,7 @@
         );
 
         // Update cache
-        $this->cache->delete($shop_id);
+        $this->cache->forget($shop_id);
     }
 
     /**
diff --git a/inc/managers/NotificationManager.php b/inc/managers/NotificationManager.php
index 5656303..5a02499 100644
--- a/inc/managers/NotificationManager.php
+++ b/inc/managers/NotificationManager.php
@@ -20,7 +20,11 @@
  */
 class NotificationManager
 {
-    protected object $cache;
+    protected Cache $userCache; //the individual notifications
+    protected Cache $contentCache;	//the 'shared' notifications on new content that has been created
+	protected Cache $artistsCache;
+	protected Cache $favouritesCache;
+	protected Cache $followerCache;
     protected string $campaign;
 	protected string $table = BASE.'notifications';
 	protected string $contentTable = BASE.'notifications_content';
@@ -139,7 +143,11 @@
      */
     public function __construct()
     {
-        $this->cache = CacheManager::for('notifications', WEEK_IN_SECONDS);
+        $this->userCache = Cache::for('userNotifications', WEEK_IN_SECONDS);
+        $this->contentCache = Cache::for('contentNotifications', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
+        $this->artistsCache = Cache::for('artist', WEEK_IN_SECONDS)->connect('post');
+        $this->favouritesCache = Cache::for('favouritedUsers', WEEK_IN_SECONDS)->connect('favourites');
+        $this->followerCache = Cache::for('totalFollowers', WEEK_IN_SECONDS)->connect('favourites');
 
         // Add filter for bulk operation handling
         add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
@@ -364,7 +372,7 @@
      */
     public function notifyVerifiedArtists(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
     {
-        $artists = $this->getVerifiedArtists();
+        $artists = $this->getVerified('artist');
         return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
     }
     /**
@@ -381,7 +389,7 @@
      */
     public function notifyVerifiedPartners(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
     {
-        $artists = $this->getVerifiedPartners();
+        $artists = $this->getVerified('partner');
         return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
     }
     /**
@@ -398,7 +406,7 @@
      */
     public function notifyEnthusiasts(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
     {
-        $artists = $this->getEnthusiasts();
+        $artists = $this->getUserIDs('enthusiast');
         return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
     }
     /**
@@ -415,7 +423,7 @@
      */
     public function notifyEveryone(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
     {
-        $artists = $this->getEveryone();
+        $artists = $this->getUserIDs(array_keys(JVB_USER));
         return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
     }
 
@@ -1100,7 +1108,7 @@
         }
 
         $content = '';
-        $cache   = CacheManager::for('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
+        $cache   = Cache::for('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
 
         // Group updates by artist
         $updates_by_artist = [];
@@ -1519,19 +1527,16 @@
     protected function getArtistData(int $user_id):array|false
     {
         // Try to get from cache
-        $cache_key = "artist_data_{$user_id}";
-        $cached = $this->cache->get($cache_key);
+		$artist_id = get_user_meta($user_id, BASE . 'link', true);
+		if (!$artist_id || $artist_id === '') {
+			return false;
+		}
+        $cached = $this->artistsCache->get($artist_id);
 
         if ($cached !== false) {
             return $cached;
         }
 
-        // Get artist post ID from user meta
-        $artist_id = get_user_meta($user_id, BASE . 'link', true);
-        if (!$artist_id) {
-            return false;
-        }
-
         // Get basic artist data
         $artist_post = get_post($artist_id);
         if (!$artist_post) {
@@ -1548,7 +1553,7 @@
         ];
 
         // Cache the result
-        $this->cache->set($cache_key, $data, null,'artists');
+        $this->artistsCache->set($artist_id, $data);
 
         return $data;
     }
@@ -1560,19 +1565,25 @@
      */
     protected function getFollowedArtists(int $user_id):array
     {
-        global $wpdb;
-        $favourites_table = $wpdb->prefix . BASE . 'favourites';
+		return $this->favouritesCache->remember(
+			$user_id,
+			function() use ($user_id) {
+				global $wpdb;
+				$favourites_table = $wpdb->prefix . BASE . 'favourites';
 
-        // Get artists this user has favourited
-        return $wpdb->get_col($wpdb->prepare(
-            "SELECT f.target_id
-         FROM {$favourites_table} f
-         JOIN {$wpdb->posts} p ON f.target_id = p.ID
-         WHERE f.user_id = %d
-         AND f.type = 'artist'
-         AND p.post_status = 'publish'",
-            $user_id
-        ));
+				// Get artists this user has favourited
+				return $wpdb->get_col($wpdb->prepare(
+				"SELECT f.target_id
+					 FROM {$favourites_table} f
+					 JOIN {$wpdb->posts} p ON f.target_id = p.ID
+					 WHERE f.user_id = %d
+					 AND f.type = 'artist'
+					 AND p.post_status = 'publish'",
+					$user_id
+				));
+			}
+		);
+
     }
 
     /**
@@ -1582,15 +1593,21 @@
      */
     protected function getFollowerCount(int $artist_id):int
     {
-        global $wpdb;
-        $favourites_table = $wpdb->prefix . BASE . 'favourites';
+		return $this->followerCache->remember(
+			$artist_id,
+			function() use ($artist_id) {
+				global $wpdb;
+				$favourites_table = $wpdb->prefix . BASE . 'favourites';
 
-        return $wpdb->get_var($wpdb->prepare(
-            "SELECT COUNT(DISTINCT user_id)
-         FROM {$favourites_table}
-         WHERE target_id = %d AND type = 'artist'",
-            $artist_id
-        ));
+				return $wpdb->get_var($wpdb->prepare(
+					"SELECT COUNT(DISTINCT user_id)
+					 FROM {$favourites_table}
+					 WHERE target_id = %d AND type = 'artist'",
+					$artist_id
+				));
+			}
+		);
+
     }
 
     /**
@@ -1598,27 +1615,16 @@
      *
      * @return string
      */
-    protected function pluralize(string $word):string
+    protected function pluralize(string $content):string
     {
-        $irregular = [
-            'tattoo' => 'tattoos',
-            'piercing' => 'piercings',
-            'artwork' => 'artwork',
-            'news' => 'news',
-            'offer' => 'offers',
-            'event' => 'events'
-        ];
-
-        if (isset($irregular[$word])) {
-            return $irregular[$word];
-        }
-
-        // Simple pluralization rules
-        if (str_ends_with($word, 'y')) {
-            return substr($word, 0, -1) . 'ies';
-        }
-
-        return $word . 's';
+        if (array_key_exists($content, JVB_CONTENT)) {
+			return JVB_CONTENT[$content]['plural'];
+		} elseif (array_key_exists($content, JVB_TAXONOMY)) {
+			return JVB_TAXONOMY[$content]['plural'];
+		} elseif (array_key_exists($content, JVB_USER)) {
+			return JVB_USER[$content]['plural'];
+		}
+		return $content;
     }
 
     /**
@@ -1628,9 +1634,8 @@
      */
     protected function clearNotificationCache(int $user_id):void
     {
-
-        $this->cache->delete("user_{$user_id}_notifications_", 'notifications_' . $user_id);
-        $this->cache->delete("user_{$user_id}_content_notifications_", 'notifications_' . $user_id);
+		$this->userCache->forget($user_id);
+		$this->contentCache->forget($user_id);
     }
 
     /**
@@ -1697,78 +1702,56 @@
     /**
      * @return array
      */
-    protected function getVerifiedArtists():array
+    protected function getVerified(string|array $userRoles):array
     {
-        $artists = $this->cache->get('verified_artists');
-        if ($artists) {
-            return $artists;
-        }
+		$userRoles = $this->checkRoles($userRoles);
 
-        $artists = get_users([
-            'role'          => BASE.'artist',
-            'capability'    => 'skip_moderation',
-            'fields'        => 'ID'
-        ]);
-
-        $this->cache->set('verified_artists', $artists);
-        return $artists;
+		if (empty($userRoles)) {
+			return [];
+		}
+		$cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user',true);
+		return $cache->remember(
+			'verified',
+			function() use ($userRoles) {
+				return get_users([
+					'role'          => $userRoles,
+					'capability'    => 'skip_moderation',
+					'fields'        => 'ID'
+				]);
+			}
+		);
     }
 
-    /**
-     * @return array
-     */
-    protected function getVerifiedPartners():array
-    {
-        $partners = $this->cache->get('verified_partners');
-        if ($partners) {
-            return $partners;
-        }
+	protected function getUserIDs(array|string $roles):array
+	{
+		$roles = $this->checkRoles($roles);
+		if (empty($roles)) {
+			return [];
+		}
+		$cache = Cache::for('everyone', DAY_IN_SECONDS)->connect('user', true);
+		return $cache->remember(
+			$cache->generateKey($roles),
+			function() use ($roles) {
+				return get_users([
+					'role'	=> $roles,
+					'fields'	=> 'ID'
+				]);
+			}
+		);
+	}
 
-        $partners = get_users([
-            'role'          => BASE.'partner',
-            'capability'    => 'skip_moderation',
-            'fields'        => 'ID'
-        ]);
+	protected function checkRoles(string|array $roles):array
+	{
+		if (!is_array($roles)) {
+			$roles = explode(',',$roles);
+		}
 
-        $this->cache->set('verified_partners', $partners);
-        return $partners;
-    }
-
-    /**
-     * @return array
-     */
-    protected function getEnthusiasts():array
-    {
-        $enthusiasts = $this->cache->get('enthusiasts');
-        if ($enthusiasts) {
-            return $enthusiasts;
-        }
-
-        $enthusiasts = get_users([
-            'role'          => BASE.'enthusiast',
-            'fields'        => 'ID'
-        ]);
-
-        $this->cache->set('enthusiasts', $enthusiasts);
-        return $enthusiasts;
-    }
-
-    /**
-     * @return array
-     */
-    protected function getEveryone():array
-    {
-        $users = $this->cache->get('users');
-        if ($users) {
-            return $users;
-        }
-        $users = get_users([
-            'role__in' => [BASE.'artist', BASE.'enthusiast', BASE.'partner'],
-            'fields'    => 'ID'
-        ]);
-        $this->cache->set('users', $users);
-        return $users;
-    }
+		return array_map(function ($r) {
+			return jvbCheckBase(trim($r));
+		}, array_filter($roles, function ($r) {
+			return array_key_exists(trim($r), JVB_USER);
+		}));
+	}
 
     /**
      * @param int $userID
@@ -1777,13 +1760,12 @@
      */
     protected function checkUser(int $userID):bool
     {
-        $checked = $this->cache->get($userID, 'checked_users');
-        if ($checked) {
-            return $checked;
-        }
-        $test = (bool)get_userdata($userID);
-
-        $this->cache->set($userID, $test, null, 'checked_users');
-        return $test;
+		$cache = Cache::for('checked_users', DAY_IN_SECONDS)->connect('user', true);
+		return $cache->remember(
+			$userID,
+			function() use ($userID) {
+				return (bool)get_userdata($userID)?:null;
+			}
+		);
     }
 }
diff --git a/inc/managers/OperationQueue.php b/inc/managers/OperationQueue.php
index 047a948..3b93ccf 100644
--- a/inc/managers/OperationQueue.php
+++ b/inc/managers/OperationQueue.php
@@ -45,7 +45,7 @@
 
     ];
 
-	protected ?CacheManager $cache = null;
+	protected ?Cache $cache = null;
 	protected int $ttl = 300;
 	// Cache keys for different data types
 	private const CACHE_QUEUE_STATUS = 'status';
@@ -59,7 +59,7 @@
     {
 		global $wpdb;
 		$this->wpdb = $wpdb;
-		$this->cache = CacheManager::for('queue', DAY_IN_SECONDS);
+		$this->cache = Cache::for('queue', DAY_IN_SECONDS)->connect('user');
         add_action('jvb_process_queue', [ $this, 'checkQueue' ]);
 		add_action('jvb_queue_maintenance', [$this, 'hourlyMaintenance']);
         add_action('jvbEmailDailyMetricsReport', [$this, 'emailDailyMetricsReport']);
@@ -759,8 +759,8 @@
 				$this->processOperation($operation);
 
 				// Invalidate operation cache after processing
-				$this->cache->delete(self::CACHE_OPERATION_PREFIX . $operation->id);
-				$this->cache->delete(self::CACHE_USER_QUEUE_PREFIX . $operation->user_id);
+				$this->cache->forget(self::CACHE_OPERATION_PREFIX . $operation->id);
+				$this->cache->forget(self::CACHE_USER_QUEUE_PREFIX . $operation->user_id);
 			}
 
 			// Batch invalidate caches at the end
@@ -955,7 +955,7 @@
 	 */
 	public function isUserQueueModified(int $user_id, int $since_timestamp): bool
 	{
-		return $this->cache::getTimestamp("user_{$user_id}") > $since_timestamp;
+		return $this->cache::lastModified("user_{$user_id}") > $since_timestamp;
 	}
 	protected function invalidateUserQueue(int $user_id): void
 	{
@@ -963,7 +963,7 @@
 		// 1. Updates HTTP timestamp for user_{$user_id}
 		// 2. Flushes user-specific caches
 		// 3. Triggers connected cache invalidation
-		CacheManager::invalidateAll("user_{$user_id}");
+		Cache::for($user_id)->flush();
 	}
 
 	/**
@@ -985,7 +985,7 @@
 		$keys = $cacheKeys[$scope] ?? $cacheKeys['all'];
 
 		foreach ($keys as $key) {
-			$this->cache->delete($key);
+			$this->cache->forget($key);
 		}
 
 		$this->cache->touch();
@@ -1491,7 +1491,7 @@
     protected function updateUserQueueTimestamp(int $user_id)
     {
 
-		CacheManager::updateTimestamp("user_{$user_id}");
+		Cache::touch("user_{$user_id}");
     }
 
 	/**
diff --git a/inc/managers/ReferralManager.php b/inc/managers/ReferralManager.php
index ade87e0..b3c1644 100644
--- a/inc/managers/ReferralManager.php
+++ b/inc/managers/ReferralManager.php
@@ -24,7 +24,9 @@
 {
 	protected $wpdb;
 	protected MagicLinkManager $magic_link;
-	protected CacheManager $cache;
+	protected Cache $cache;
+	protected Cache $requestCache;
+	protected Cache $statsCache;
 	protected string $referrals_table;
 	protected ?int $referralPage = null;
 	protected string $rewards_table;
@@ -48,7 +50,10 @@
 	{
 		global $wpdb;
 		$this->wpdb = $wpdb;
-		$this->cache = CacheManager::for('referrals', WEEK_IN_SECONDS);
+		$this->cache = Cache::for('referrals', WEEK_IN_SECONDS);
+		$this->requestCache = Cache::for('referral_requests', WEEK_IN_SECONDS)->connect('referrals', true);
+		$this->statsCache = Cache::for('referral_stats', WEEK_IN_SECONDS)->connect('referrals', true);
+
 		$this->referrals_table = $wpdb->prefix . BASE . 'referrals';
 		$this->rewards_table = $wpdb->prefix . BASE . 'referral_rewards';
 
@@ -355,7 +360,7 @@
 		}
 
 		// Clear caches
-		$this->cache->clear();
+		$this->cache->flush();
 
 		// Fire action for tracking
 		do_action('jvb_referral_processed', $user_id, $referrer->ID, $referral_code);
@@ -519,19 +524,19 @@
 	 */
 	public function getUserReferrals(int $user_id, array $args = []): array
 	{
-		return $this->cache->remember(
-			$user_id,
+		$defaults = [
+			'status' => 'all',
+			'limit' => 100,
+			'offset' => 0,
+			'orderby' => 'referred_at',
+			'order' => 'DESC'
+		];
+
+		$args = wp_parse_args($args, $defaults);
+
+		return $this->requestCache->remember(
+			$this->requestCache->generateKey(array_merge(['user'=>$user_id], $args)),
 			function() use ($user_id, $args) {
-				$defaults = [
-					'status' => 'all',
-					'limit' => 100,
-					'offset' => 0,
-					'orderby' => 'referred_at',
-					'order' => 'DESC'
-				];
-
-				$args = wp_parse_args($args, $defaults);
-
 				$where = $this->wpdb->prepare("WHERE referrer_id = %d", $user_id);
 
 				if ($args['status'] !== 'all') {
@@ -575,37 +580,33 @@
 	 */
 	public function getUserStats(int $user_id): array
 	{
-		$cache_key = 'stats_' . $user_id;
-		$cached = $this->cache->get($cache_key);
-
-		if ($cached !== false) {
-			return $cached;
-		}
-
-		$stats = $this->wpdb->get_row($this->wpdb->prepare(
-			"SELECT
+		return $this->statsCache->remember(
+			$user_id,
+			function() use ($user_id) {
+				$stats = $this->wpdb->get_row($this->wpdb->prepare(
+					"SELECT
 			COUNT(*) as code_used,
 			SUM(CASE WHEN status IN ('consulted', 'treated') THEN 1 ELSE 0 END) as consultations,
 			SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treatments,
 			SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending
 		FROM {$this->referrals_table}
 		WHERE referrer_id = %d",
-			$user_id
-		), ARRAY_A);
+					$user_id
+				), ARRAY_A);
 
-		// Get total rewards earned (available + redeemed)
-		$rewards = $this->wpdb->get_var($this->wpdb->prepare(
-			"SELECT SUM(amount)
+				// Get total rewards earned (available + redeemed)
+				$rewards = $this->wpdb->get_var($this->wpdb->prepare(
+					"SELECT SUM(amount)
 		FROM {$this->rewards_table}
 		WHERE user_id = %d AND reward_type = 'referrer'",
-			$user_id
-		));
+					$user_id
+				));
 
-		$stats['total_rewards'] = floatval($rewards ?? 0);
-		$stats['user_id'] = $user_id;
-		$this->cache->set($cache_key, $stats, HOUR_IN_SECONDS);
-
-		return $stats;
+				$stats['total_rewards'] = floatval($rewards ?? 0);
+				$stats['user_id'] = $user_id;
+				return $stats;
+			}
+		);
 	}
 
 	/**
@@ -617,20 +618,23 @@
 	 */
 	public function getTopReferrers(int $limit = 10, string $period = 'all'): array
 	{
-		$where = '';
+		return $this->statsCache->remember(
+			$this->statsCache->generateKey(['limit'=>$limit, 'period' => $period]),
+			function() use ($limit, $period) {
+				$where = '';
 
-		if ($period !== 'all') {
-			$date_where = match($period) {
-				'day' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
-				'week' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)",
-				'month' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)",
-				default => "1=1"
-			};
+				if ($period !== 'all') {
+					$date_where = match($period) {
+						'day' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
+						'week' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)",
+						'month' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)",
+						default => "1=1"
+					};
 
-			$where = "WHERE {$date_where}";
-		}
+					$where = "WHERE {$date_where}";
+				}
 
-		$query = "SELECT
+				$query = "SELECT
                     referrer_id,
                     COUNT(*) as referral_count,
                     SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treated_count
@@ -640,16 +644,19 @@
                   ORDER BY referral_count DESC
                   LIMIT {$limit}";
 
-		$results = $this->wpdb->get_results($query);
+				$results = $this->wpdb->get_results($query);
 
-		// Enrich with user data
-		foreach ($results as &$result) {
-			$user = get_user_by('ID', $result->referrer_id);
-			$result->user_name = $user ? $user->display_name : 'Unknown';
-			$result->user_email = $user ? $user->user_email : '';
-		}
+				// Enrich with user data
+				foreach ($results as &$result) {
+					$user = get_user_by('ID', $result->referrer_id);
+					$result->user_name = $user ? $user->display_name : 'Unknown';
+					$result->user_email = $user ? $user->user_email : '';
+				}
 
-		return $results;
+				return $results;
+			}
+		);
+
 	}
 
 	/**
@@ -772,23 +779,30 @@
 	 */
 	protected function generateCSV(array $referrals): string
 	{
-		$csv = "Referred By,Referee Name,Referee Email,Referee Phone,Referral Code,Status,Referred At,Treated At\n";
+		$cache = Cache::for('referralCSV', HOUR_IN_SECONDS)->connect('referrals');
+		return $cache->remember(
+			'csv',
+			function () use ($referrals) {
+				$csv = "Referred By,Referee Name,Referee Email,Referee Phone,Referral Code,Status,Referred At,Treated At\n";
 
-		foreach ($referrals as $referral) {
-			$csv .= sprintf(
-				'"%s","%s","%s","%s","%s","%s","%s","%s"' . "\n",
-				$referral->referrer_name ?? 'Unknown',
-				$referral->referee_name,
-				$referral->referee_email,
-				$referral->referee_phone,
-				$referral->referral_code,
-				$referral->status,
-				$referral->referred_at,
-				$referral->treated_at ?? 'Not yet'
-			);
-		}
+				foreach ($referrals as $referral) {
+					$csv .= sprintf(
+						'"%s","%s","%s","%s","%s","%s","%s","%s"' . "\n",
+						$referral->referrer_name ?? 'Unknown',
+						$referral->referee_name,
+						$referral->referee_email,
+						$referral->referee_phone,
+						$referral->referral_code,
+						$referral->status,
+						$referral->referred_at,
+						$referral->treated_at ?? 'Not yet'
+					);
+				}
 
-		return $csv;
+				return $csv;
+			}
+		);
+
 	}
 
 	/**
diff --git a/inc/managers/ReferralManager2.php b/inc/managers/ReferralManager2.php
deleted file mode 100644
index 3f299fc..0000000
--- a/inc/managers/ReferralManager2.php
+++ /dev/null
@@ -1,1006 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\JVB;
-use WP_Error;
-use WP_REST_Response;
-use Exception;
-
-if (!defined('ABSPATH')) {
-	exit;
-}
-
-/**
- * Referral Tracking System
- *
- * Manages user referral codes, tracking, and rewards
- * Uses existing infrastructure: MetaManager, CacheManager, NotificationManager
- */
-class ReferralManager
-{
-	protected $wpdb;
-	protected $cache;
-	protected $table_codes;
-	protected $table_usage;
-	protected $table_rewards;
-
-	// Default reward settings
-	const DEFAULT_REFERRER_REWARD_TYPE = 'per_user'; // or 'flat_total'
-	const DEFAULT_REFERRER_REWARD_AMOUNT = 25.00;
-	const DEFAULT_REFERRED_REWARD_TYPE = 'percentage'; // or 'fixed'
-	const DEFAULT_REFERRED_REWARD_AMOUNT = 20; // 20% or $20
-
-	public function __construct()
-	{
-		global $wpdb;
-		$this->wpdb = $wpdb;
-		$this->cache = JVB()->cache();
-
-		$this->table_codes = $wpdb->prefix . BASE . 'referral_codes';
-		$this->table_usage = $wpdb->prefix . BASE . 'referral_usage';
-		$this->table_rewards = $wpdb->prefix . BASE . 'referral_rewards';
-
-		$this->registerHooks();
-	}
-
-	/**
-	 * Register WordPress hooks
-	 */
-	protected function registerHooks(): void
-	{
-		// Track new user registrations with referral codes
-		add_action('user_register', [$this, 'trackReferralRegistration'], 10, 2);
-
-		// Monthly report cron
-		add_action(BASE . 'referral_monthly_report', [$this, 'generateMonthlyReports']);
-
-		// Cleanup expired codes
-		add_action(BASE . 'cleanup_referrals', [$this, 'cleanupExpiredCodes']);
-
-		// Handle bulk operations
-		add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
-	}
-
-	/************************************************************
-	 * Referral Code Management
-	 ************************************************************/
-
-	/**
-	 * Create or update a user's referral code
-	 *
-	 * @param int $user_id User ID
-	 * @param string|null $custom_code Optional custom code (must be unique)
-	 * @return array|WP_Error
-	 */
-	public function createReferralCode(int $user_id, ?string $custom_code = null): array|WP_Error
-	{
-		// Validate user
-		if (!$this->validateUser($user_id)) {
-			return new WP_Error('invalid_user', 'Invalid user ID');
-		}
-
-		// Check if user already has a code
-		$existing = $this->getUserReferralCode($user_id);
-
-		if ($existing && !$custom_code) {
-			return $existing; // Return existing code if no custom code requested
-		}
-
-		// Generate or validate custom code
-		$code = $custom_code ? $this->sanitizeCode($custom_code) : $this->generateUniqueCode($user_id);
-
-		// Check if code is already taken
-		if ($this->isCodeTaken($code, $user_id)) {
-			return new WP_Error('code_taken', 'This referral code is already in use');
-		}
-
-		// Validate code format
-		if (!$this->validateCodeFormat($code)) {
-			return new WP_Error('invalid_format', 'Code must be 4-20 alphanumeric characters');
-		}
-
-		$data = [
-			'user_id' => $user_id,
-			'code' => $code,
-			'is_active' => 1,
-			'created_at' => current_time('mysql'),
-			'updated_at' => current_time('mysql')
-		];
-
-		if ($existing) {
-			// Update existing code
-			$result = $this->wpdb->update(
-				$this->table_codes,
-				['code' => $code, 'updated_at' => current_time('mysql')],
-				['user_id' => $user_id]
-			);
-		} else {
-			// Insert new code
-			$result = $this->wpdb->insert($this->table_codes, $data);
-		}
-
-		if ($result === false) {
-			return new WP_Error('db_error', 'Failed to save referral code');
-		}
-
-		// Clear cache
-		$this->cache->delete('referral_code_' . $user_id);
-		$this->cache->delete('referral_user_' . $code);
-
-		return [
-			'success' => true,
-			'code' => $code,
-			'url' => $this->getReferralUrl($code)
-		];
-	}
-
-	/**
-	 * Get user's referral code
-	 *
-	 * @param int $user_id User ID
-	 * @return array|null
-	 */
-	public function getUserReferralCode(int $user_id): ?array
-	{
-		$cache_key = 'referral_code_' . $user_id;
-		$cached = $this->cache->get($cache_key);
-
-		if ($cached !== false) {
-			return $cached;
-		}
-
-		$result = $this->wpdb->get_row($this->wpdb->prepare(
-			"SELECT * FROM {$this->table_codes} WHERE user_id = %d",
-			$user_id
-		), ARRAY_A);
-
-		if ($result) {
-			$result['url'] = $this->getReferralUrl($result['code']);
-			$result['stats'] = $this->getCodeStats($result['code']);
-			$this->cache->set($cache_key, $result, 3600);
-		}
-
-		return $result;
-	}
-
-	/**
-	 * Get referral code statistics
-	 *
-	 * @param string $code Referral code
-	 * @return array
-	 */
-	public function getCodeStats(string $code): array
-	{
-		$cache_key = 'referral_stats_' . $code;
-		$cached = $this->cache->get($cache_key);
-
-		if ($cached !== false) {
-			return $cached;
-		}
-
-		$stats = $this->wpdb->get_row($this->wpdb->prepare(
-			"SELECT
-                COUNT(*) as total_uses,
-                COUNT(CASE WHEN registered_at IS NOT NULL THEN 1 END) as completed_registrations,
-                COUNT(CASE WHEN first_order_at IS NOT NULL THEN 1 END) as converted_orders
-             FROM {$this->table_usage}
-             WHERE referral_code = %s",
-			$code
-		), ARRAY_A);
-
-		$this->cache->set($cache_key, $stats, 1800);
-		return $stats ?: ['total_uses' => 0, 'completed_registrations' => 0, 'converted_orders' => 0];
-	}
-
-	/**
-	 * Get user ID from referral code
-	 *
-	 * @param string $code Referral code
-	 * @return int|null User ID or null
-	 */
-	public function getUserFromCode(string $code): ?int
-	{
-		$cache_key = 'referral_user_' . $code;
-		$cached = $this->cache->get($cache_key);
-
-		if ($cached !== false) {
-			return $cached;
-		}
-
-		$user_id = $this->wpdb->get_var($this->wpdb->prepare(
-			"SELECT user_id FROM {$this->table_codes}
-             WHERE code = %s AND is_active = 1",
-			$code
-		));
-
-		if ($user_id) {
-			$this->cache->set($cache_key, (int)$user_id, 3600);
-			return (int)$user_id;
-		}
-
-		return null;
-	}
-
-	/************************************************************
-	 * Referral Tracking
-	 ************************************************************/
-
-	/**
-	 * Track when someone clicks a referral link
-	 *
-	 * @param string $code Referral code
-	 * @param string|null $email Optional email if user provides it
-	 * @return array|WP_Error
-	 */
-	public function trackReferralClick(string $code, ?string $email = null): array|WP_Error
-	{
-		$user_id = $this->getUserFromCode($code);
-
-		if (!$user_id) {
-			return new WP_Error('invalid_code', 'Invalid referral code');
-		}
-
-		// Check if this email/IP already used this code recently (prevent duplicate tracking)
-		$ip_address = $this->getClientIp();
-
-		$existing = $this->wpdb->get_var($this->wpdb->prepare(
-			"SELECT id FROM {$this->table_usage}
-             WHERE referral_code = %s
-             AND (email = %s OR ip_address = %s)
-             AND clicked_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)",
-			$code,
-			$email ?: '',
-			$ip_address
-		));
-
-		if ($existing) {
-			return ['success' => true, 'message' => 'Already tracked'];
-		}
-
-		// Track the click
-		$data = [
-			'referral_code' => $code,
-			'referrer_user_id' => $user_id,
-			'email' => $email,
-			'ip_address' => $ip_address,
-			'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
-			'clicked_at' => current_time('mysql')
-		];
-
-		$result = $this->wpdb->insert($this->table_usage, $data);
-
-		if ($result === false) {
-			return new WP_Error('db_error', 'Failed to track referral');
-		}
-
-		// Store in cookie for 30 days
-		setcookie('jvb_referral', $code, time() + (86400 * 30), '/');
-
-		return [
-			'success' => true,
-			'tracking_id' => $this->wpdb->insert_id
-		];
-	}
-
-	/**
-	 * Track referral when user registers
-	 *
-	 * @param int $new_user_id Newly registered user ID
-	 * @param array $userdata User data
-	 * @return void
-	 */
-	public function trackReferralRegistration(int $new_user_id, array $userdata = []): void
-	{
-		// Check for referral code in cookie or GET parameter
-		$code = $_COOKIE['jvb_referral'] ?? $_GET['ref'] ?? null;
-
-		if (!$code) {
-			return;
-		}
-
-		$user = get_userdata($new_user_id);
-		if (!$user) {
-			return;
-		}
-
-		// Update or create usage record
-		$usage = $this->wpdb->get_row($this->wpdb->prepare(
-			"SELECT * FROM {$this->table_usage}
-             WHERE referral_code = %s
-             AND (email = %s OR ip_address = %s)
-             ORDER BY clicked_at DESC LIMIT 1",
-			$code,
-			$user->user_email,
-			$this->getClientIp()
-		), ARRAY_A);
-
-		if ($usage) {
-			// Update existing record
-			$this->wpdb->update(
-				$this->table_usage,
-				[
-					'referred_user_id' => $new_user_id,
-					'email' => $user->user_email,
-					'registered_at' => current_time('mysql')
-				],
-				['id' => $usage['id']]
-			);
-		} else {
-			// Create new record (direct registration with code)
-			$referrer_id = $this->getUserFromCode($code);
-
-			if ($referrer_id) {
-				$this->wpdb->insert($this->table_usage, [
-					'referral_code' => $code,
-					'referrer_user_id' => $referrer_id,
-					'referred_user_id' => $new_user_id,
-					'email' => $user->user_email,
-					'ip_address' => $this->getClientIp(),
-					'clicked_at' => current_time('mysql'),
-					'registered_at' => current_time('mysql')
-				]);
-			}
-		}
-
-		// Clear cache
-		$this->cache->delete('referral_stats_' . $code);
-
-		// Notify referrer
-		if (isset($referrer_id) && $referrer_id) {
-			$this->notifyReferrer($referrer_id, $new_user_id);
-		}
-	}
-
-	/**
-	 * Track when referred user makes first order
-	 *
-	 * @param int $user_id User who made order
-	 * @param float $order_amount Order amount
-	 * @return void
-	 */
-	public function trackFirstOrder(int $user_id, float $order_amount): void
-	{
-		$usage = $this->wpdb->get_row($this->wpdb->prepare(
-			"SELECT * FROM {$this->table_usage}
-             WHERE referred_user_id = %d
-             AND first_order_at IS NULL",
-			$user_id
-		), ARRAY_A);
-
-		if (!$usage) {
-			return;
-		}
-
-		// Update usage record
-		$this->wpdb->update(
-			$this->table_usage,
-			[
-				'first_order_at' => current_time('mysql'),
-				'first_order_amount' => $order_amount
-			],
-			['id' => $usage['id']]
-		);
-
-		// Process rewards
-		$this->processRewards($usage['referrer_user_id'], $user_id, $order_amount);
-
-		// Clear cache
-		$this->cache->delete('referral_stats_' . $usage['referral_code']);
-	}
-
-	/************************************************************
-	 * Reward Management
-	 ************************************************************/
-
-	/**
-	 * Process referral rewards
-	 *
-	 * @param int $referrer_id User who referred
-	 * @param int $referred_id User who was referred
-	 * @param float $order_amount First order amount
-	 * @return void
-	 */
-	protected function processRewards(int $referrer_id, int $referred_id, float $order_amount): void
-	{
-		// Get reward settings
-		$settings = $this->getRewardSettings();
-
-		// Calculate referrer reward
-		$referrer_amount = $this->calculateReferrerReward($referrer_id, $settings);
-
-		if ($referrer_amount > 0) {
-			$this->addReward($referrer_id, 'referrer', $referrer_amount, $referred_id);
-		}
-
-		// Calculate referred user reward (already applied at checkout)
-		$referred_amount = $this->calculateReferredReward($order_amount, $settings);
-
-		if ($referred_amount > 0) {
-			$this->addReward($referred_id, 'referred', $referred_amount, $referrer_id);
-		}
-	}
-
-	/**
-	 * Calculate referrer reward amount
-	 *
-	 * @param int $referrer_id Referrer user ID
-	 * @param array $settings Reward settings
-	 * @return float Reward amount
-	 */
-	protected function calculateReferrerReward(int $referrer_id, array $settings): float
-	{
-		$type = $settings['referrer_reward_type'];
-		$amount = floatval($settings['referrer_reward_amount']);
-
-		if ($type === 'per_user') {
-			return $amount;
-		}
-
-		// For 'flat_total', check if total reward cap reached
-		$total_earned = $this->getTotalRewardsEarned($referrer_id, 'referrer');
-
-		if ($total_earned >= $amount) {
-			return 0; // Cap reached
-		}
-
-		return min($settings['referrer_reward_per_user'] ?? 25.00, $amount - $total_earned);
-	}
-
-	/**
-	 * Calculate referred user reward
-	 *
-	 * @param float $order_amount Order amount
-	 * @param array $settings Reward settings
-	 * @return float Discount amount
-	 */
-	protected function calculateReferredReward(float $order_amount, array $settings): float
-	{
-		$type = $settings['referred_reward_type'];
-		$amount = floatval($settings['referred_reward_amount']);
-
-		if ($type === 'percentage') {
-			return $order_amount * ($amount / 100);
-		}
-
-		return min($amount, $order_amount); // Fixed amount, but not more than order
-	}
-
-	/**
-	 * Add reward to user's account
-	 *
-	 * @param int $user_id User receiving reward
-	 * @param string $type 'referrer' or 'referred'
-	 * @param float $amount Reward amount
-	 * @param int $related_user_id Related user ID
-	 * @return bool
-	 */
-	protected function addReward(int $user_id, string $type, float $amount, int $related_user_id): bool
-	{
-		$data = [
-			'user_id' => $user_id,
-			'reward_type' => $type,
-			'amount' => $amount,
-			'related_user_id' => $related_user_id,
-			'status' => 'pending',
-			'created_at' => current_time('mysql')
-		];
-
-		$result = $this->wpdb->insert($this->table_rewards, $data);
-
-		if ($result) {
-			// Notify user
-			$notification_type = $type === 'referrer' ? 'referral_reward_earned' : 'referral_reward_received';
-			JVB()->notification()->addNotification(
-				$user_id,
-				$notification_type,
-				null,
-				sprintf('You earned $%.2f in referral rewards!', $amount)
-			);
-
-			return true;
-		}
-
-		return false;
-	}
-
-	/**
-	 * Get total rewards earned by user
-	 *
-	 * @param int $user_id User ID
-	 * @param string|null $type Optional reward type filter
-	 * @return float Total amount
-	 */
-	public function getTotalRewardsEarned(int $user_id, ?string $type = null): float
-	{
-		$sql = "SELECT SUM(amount) FROM {$this->table_rewards} WHERE user_id = %d";
-		$params = [$user_id];
-
-		if ($type) {
-			$sql .= " AND reward_type = %s";
-			$params[] = $type;
-		}
-
-		$total = $this->wpdb->get_var($this->wpdb->prepare($sql, $params));
-		return floatval($total);
-	}
-
-	/**
-	 * Get user's available reward balance
-	 *
-	 * @param int $user_id User ID
-	 * @return float Available balance
-	 */
-	public function getAvailableBalance(int $user_id): float
-	{
-		$total = $this->wpdb->get_var($this->wpdb->prepare(
-			"SELECT SUM(amount) FROM {$this->table_rewards}
-             WHERE user_id = %d AND status IN ('pending', 'available')",
-			$user_id
-		));
-
-		return floatval($total);
-	}
-
-	/************************************************************
-	 * Monthly Reports
-	 ************************************************************/
-
-	/**
-	 * Generate monthly reports for all users with referrals
-	 *
-	 * @return void
-	 */
-	public function generateMonthlyReports(): void
-	{
-		$first_day = date('Y-m-01', strtotime('last month'));
-		$last_day = date('Y-m-t', strtotime('last month'));
-
-		// Get all users who had referral activity last month
-		$users = $this->wpdb->get_col($this->wpdb->prepare(
-			"SELECT DISTINCT referrer_user_id
-             FROM {$this->table_usage}
-             WHERE registered_at BETWEEN %s AND %s
-             OR first_order_at BETWEEN %s AND %s",
-			$first_day, $last_day, $first_day, $last_day
-		));
-
-		if (empty($users)) {
-			return;
-		}
-
-		// Queue report generation
-		$queue = JVB()->queue();
-		$queue->queueOperation(
-			'generate_referral_report',
-			0,
-			[
-				'users' => $users,
-				'period_start' => $first_day,
-				'period_end' => $last_day
-			],
-			[
-				'count' => count($users),
-				'chunk_key' => 'users',
-				'chunk_size' => 10,
-				'priority' => 'low'
-			]
-		);
-	}
-
-	/**
-	 * Generate report for a single user
-	 *
-	 * @param int $user_id User ID
-	 * @param string $period_start Start date
-	 * @param string $period_end End date
-	 * @return array|WP_Error
-	 */
-	public function generateUserReport(int $user_id, string $period_start, string $period_end): array|WP_Error
-	{
-		$user = get_userdata($user_id);
-		if (!$user) {
-			return new WP_Error('invalid_user', 'Invalid user');
-		}
-
-		$code = $this->getUserReferralCode($user_id);
-		if (!$code) {
-			return new WP_Error('no_code', 'User has no referral code');
-		}
-
-		// Get activity for period
-		$activity = $this->wpdb->get_results($this->wpdb->prepare(
-			"SELECT * FROM {$this->table_usage}
-             WHERE referrer_user_id = %d
-             AND (
-                (registered_at BETWEEN %s AND %s)
-                OR (first_order_at BETWEEN %s AND %s)
-             )
-             ORDER BY registered_at DESC",
-			$user_id, $period_start, $period_end, $period_start, $period_end
-		), ARRAY_A);
-
-		// Generate CSV
-		$csv_path = $this->generateActivityCSV($user_id, $activity, $period_start, $period_end);
-
-		// Send email with CSV attachment
-		$this->sendMonthlyReportEmail($user, $activity, $csv_path, $period_start, $period_end);
-
-		return [
-			'success' => true,
-			'user_id' => $user_id,
-			'activity_count' => count($activity)
-		];
-	}
-
-	/**
-	 * Generate CSV file for activity
-	 *
-	 * @param int $user_id User ID
-	 * @param array $activity Activity records
-	 * @param string $period_start Start date
-	 * @param string $period_end End date
-	 * @return string File path
-	 */
-	protected function generateActivityCSV(int $user_id, array $activity, string $period_start, string $period_end): string
-	{
-		$upload_dir = wp_upload_dir();
-		$filename = sprintf(
-			'referral-report-%d-%s-to-%s.csv',
-			$user_id,
-			$period_start,
-			$period_end
-		);
-		$filepath = $upload_dir['basedir'] . '/referral-reports/' . $filename;
-
-		// Create directory if needed
-		wp_mkdir_p(dirname($filepath));
-
-		$fp = fopen($filepath, 'w');
-
-		// Headers
-		fputcsv($fp, [
-			'Date',
-			'Type',
-			'Email',
-			'User ID',
-			'Status',
-			'Order Amount',
-			'Reward Earned'
-		]);
-
-		// Data rows
-		foreach ($activity as $record) {
-			$type = $record['registered_at'] ? 'Registration' : 'Click';
-			if ($record['first_order_at']) {
-				$type = 'First Order';
-			}
-
-			fputcsv($fp, [
-				$record['registered_at'] ?? $record['clicked_at'],
-				$type,
-				$record['email'],
-				$record['referred_user_id'] ?? 'N/A',
-				$record['first_order_at'] ? 'Converted' : ($record['registered_at'] ? 'Registered' : 'Pending'),
-				$record['first_order_amount'] ?? 'N/A',
-				$this->getRewardForUsage($record['id'])
-			]);
-		}
-
-		fclose($fp);
-		return $filepath;
-	}
-
-	/**
-	 * Get reward amount for usage record
-	 *
-	 * @param int $usage_id Usage ID
-	 * @return string Formatted amount or N/A
-	 */
-	protected function getRewardForUsage(int $usage_id): string
-	{
-		$usage = $this->wpdb->get_row($this->wpdb->prepare(
-			"SELECT * FROM {$this->table_usage} WHERE id = %d",
-			$usage_id
-		), ARRAY_A);
-
-		if (!$usage || !$usage['referred_user_id']) {
-			return 'N/A';
-		}
-
-		$reward = $this->wpdb->get_var($this->wpdb->prepare(
-			"SELECT amount FROM {$this->table_rewards}
-             WHERE user_id = %d AND related_user_id = %d
-             AND reward_type = 'referrer'",
-			$usage['referrer_user_id'],
-			$usage['referred_user_id']
-		));
-
-		return $reward ? '$' . number_format($reward, 2) : 'N/A';
-	}
-
-	/**
-	 * Send monthly report email
-	 *
-	 * @param object $user User object
-	 * @param array $activity Activity records
-	 * @param string $csv_path Path to CSV file
-	 * @param string $period_start Start date
-	 * @param string $period_end End date
-	 * @return bool
-	 */
-	protected function sendMonthlyReportEmail($user, array $activity, string $csv_path, string $period_start, string $period_end): bool
-	{
-		$total_clicks = count(array_filter($activity, fn($a) => !empty($a['clicked_at'])));
-		$total_registrations = count(array_filter($activity, fn($a) => !empty($a['registered_at'])));
-		$total_orders = count(array_filter($activity, fn($a) => !empty($a['first_order_at'])));
-
-		$total_earned = $this->wpdb->get_var($this->wpdb->prepare(
-			"SELECT SUM(r.amount) FROM {$this->table_rewards} r
-             INNER JOIN {$this->table_usage} u ON r.related_user_id = u.referred_user_id
-             WHERE r.user_id = %d
-             AND r.reward_type = 'referrer'
-             AND r.created_at BETWEEN %s AND %s",
-			$user->ID, $period_start, $period_end
-		));
-
-		$subject = sprintf(
-			'Your Referral Report for %s',
-			date('F Y', strtotime($period_start))
-		);
-
-		$message = sprintf(
-			"Hi %s,\n\n" .
-			"Here's your referral activity summary for %s:\n\n" .
-			"📊 Activity Overview:\n" .
-			"- Clicks: %d\n" .
-			"- New Registrations: %d\n" .
-			"- First Orders: %d\n" .
-			"- Total Earned: $%.2f\n\n" .
-			"Your current reward balance: $%.2f\n\n" .
-			"Detailed activity is attached as a CSV file.\n\n" .
-			"Keep sharing your referral link to earn more rewards!\n" .
-			"Your link: %s\n\n" .
-			"Thanks,\n%s",
-			$user->display_name,
-			date('F Y', strtotime($period_start)),
-			$total_clicks,
-			$total_registrations,
-			$total_orders,
-			floatval($total_earned),
-			$this->getAvailableBalance($user->ID),
-			$this->getReferralUrl($this->getUserReferralCode($user->ID)['code']),
-			get_bloginfo('name')
-		);
-
-		return wp_mail(
-			$user->user_email,
-			$subject,
-			$message,
-			['Content-Type: text/plain; charset=UTF-8'],
-			[$csv_path]
-		);
-	}
-
-	/************************************************************
-	 * Settings & Configuration
-	 ************************************************************/
-
-	/**
-	 * Get referral reward settings
-	 *
-	 * @return array Settings
-	 */
-	public function getRewardSettings(): array
-	{
-		$defaults = [
-			'referrer_reward_type' => self::DEFAULT_REFERRER_REWARD_TYPE,
-			'referrer_reward_amount' => self::DEFAULT_REFERRER_REWARD_AMOUNT,
-			'referrer_reward_per_user' => self::DEFAULT_REFERRER_REWARD_AMOUNT,
-			'referred_reward_type' => self::DEFAULT_REFERRED_REWARD_TYPE,
-			'referred_reward_amount' => self::DEFAULT_REFERRED_REWARD_AMOUNT
-		];
-
-		// Get from options (can be customized in admin settings)
-		$saved = get_option(BASE . 'referral_settings', []);
-
-		return array_merge($defaults, $saved);
-	}
-
-	/**
-	 * Update referral reward settings
-	 *
-	 * @param array $settings New settings
-	 * @return bool
-	 */
-	public function updateRewardSettings(array $settings): bool
-	{
-		$valid_settings = [];
-
-		if (isset($settings['referrer_reward_type'])) {
-			$valid_settings['referrer_reward_type'] = in_array($settings['referrer_reward_type'], ['per_user', 'flat_total'])
-				? $settings['referrer_reward_type']
-				: self::DEFAULT_REFERRER_REWARD_TYPE;
-		}
-
-		if (isset($settings['referrer_reward_amount'])) {
-			$valid_settings['referrer_reward_amount'] = max(0, floatval($settings['referrer_reward_amount']));
-		}
-
-		if (isset($settings['referrer_reward_per_user'])) {
-			$valid_settings['referrer_reward_per_user'] = max(0, floatval($settings['referrer_reward_per_user']));
-		}
-
-		if (isset($settings['referred_reward_type'])) {
-			$valid_settings['referred_reward_type'] = in_array($settings['referred_reward_type'], ['percentage', 'fixed'])
-				? $settings['referred_reward_type']
-				: self::DEFAULT_REFERRED_REWARD_TYPE;
-		}
-
-		if (isset($settings['referred_reward_amount'])) {
-			$valid_settings['referred_reward_amount'] = max(0, floatval($settings['referred_reward_amount']));
-		}
-
-		return update_option(BASE . 'referral_settings', $valid_settings);
-	}
-
-	/************************************************************
-	 * Helper Methods
-	 ************************************************************/
-
-	/**
-	 * Generate unique referral code
-	 *
-	 * @param int $user_id User ID
-	 * @return string Unique code
-	 */
-	protected function generateUniqueCode(int $user_id): string
-	{
-		$user = get_userdata($user_id);
-		$base = strtoupper(substr($user->user_login, 0, 6));
-		$code = $base . rand(1000, 9999);
-
-		// Ensure uniqueness
-		while ($this->isCodeTaken($code)) {
-			$code = $base . rand(1000, 9999);
-		}
-
-		return $code;
-	}
-
-	/**
-	 * Sanitize referral code
-	 *
-	 * @param string $code Raw code
-	 * @return string Sanitized code
-	 */
-	protected function sanitizeCode(string $code): string
-	{
-		return strtoupper(preg_replace('/[^A-Z0-9]/', '', strtoupper($code)));
-	}
-
-	/**
-	 * Check if code is already taken
-	 *
-	 * @param string $code Code to check
-	 * @param int|null $exclude_user_id User ID to exclude
-	 * @return bool
-	 */
-	protected function isCodeTaken(string $code, ?int $exclude_user_id = null): bool
-	{
-		$sql = "SELECT COUNT(*) FROM {$this->table_codes} WHERE code = %s";
-		$params = [$code];
-
-		if ($exclude_user_id) {
-			$sql .= " AND user_id != %d";
-			$params[] = $exclude_user_id;
-		}
-
-		$count = $this->wpdb->get_var($this->wpdb->prepare($sql, $params));
-		return $count > 0;
-	}
-
-	/**
-	 * Validate code format
-	 *
-	 * @param string $code Code to validate
-	 * @return bool
-	 */
-	protected function validateCodeFormat(string $code): bool
-	{
-		return preg_match('/^[A-Z0-9]{4,20}$/', $code);
-	}
-
-	/**
-	 * Validate user ID
-	 *
-	 * @param int $user_id User ID
-	 * @return bool
-	 */
-	protected function validateUser(int $user_id): bool
-	{
-		return get_userdata($user_id) !== false;
-	}
-
-	/**
-	 * Get referral URL for code
-	 *
-	 * @param string $code Referral code
-	 * @return string Full URL
-	 */
-	protected function getReferralUrl(string $code): string
-	{
-		return add_query_arg('ref', $code, home_url('/register'));
-	}
-
-	/**
-	 * Get client IP address
-	 *
-	 * @return string IP address
-	 */
-	protected function getClientIp(): string
-	{
-		$ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ??
-			$_SERVER['HTTP_X_FORWARDED_FOR'] ??
-			$_SERVER['REMOTE_ADDR'] ??
-			'0.0.0.0';
-
-		return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
-	}
-
-	/**
-	 * Notify referrer of new registration
-	 *
-	 * @param int $referrer_id Referrer user ID
-	 * @param int $referred_id Referred user ID
-	 * @return void
-	 */
-	protected function notifyReferrer(int $referrer_id, int $referred_id): void
-	{
-		JVB()->notification()->addNotification(
-			$referrer_id,
-			'referral_signup',
-			$referred_id,
-			'Someone signed up using your referral code!'
-		);
-	}
-
-	/**
-	 * Cleanup expired/old records
-	 *
-	 * @return void
-	 */
-	public function cleanupExpiredCodes(): void
-	{
-		// Delete clicks older than 90 days with no registration
-		$this->wpdb->query(
-			"DELETE FROM {$this->table_usage}
-             WHERE clicked_at < DATE_SUB(NOW(), INTERVAL 90 DAY)
-             AND registered_at IS NULL"
-		);
-	}
-
-	/**
-	 * Handle bulk operations
-	 *
-	 * @param mixed $result Default result
-	 * @param object $operation Operation object
-	 * @param array $data Operation data
-	 * @return mixed
-	 */
-	public function processOperation($result, object $operation, array $data)
-	{
-		if ($operation->type === 'generate_referral_report') {
-			$user_id = $data['users'][$operation->progress_count] ?? null;
-
-			if ($user_id) {
-				return $this->generateUserReport(
-					$user_id,
-					$data['period_start'],
-					$data['period_end']
-				);
-			}
-		}
-
-		return $result;
-	}
-}
diff --git a/inc/managers/RegisterManager.php b/inc/managers/RegisterManager.php
deleted file mode 100644
index 185a1d1..0000000
--- a/inc/managers/RegisterManager.php
+++ /dev/null
@@ -1,1298 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\meta\MetaManager;
-use WP_Error;
-use WP_User;
-
-if (!defined('ABSPATH')) {
-    exit; // Exit if accessed directly
-}
-
-class RegisterManager
-{
-    private array|null $invitation_data = null;
-    protected array $inviteData = [];
-    private array $allowed_file_types = [
-        'image/jpeg',
-        'image/png',
-        'image/gif',
-        'application/pdf'
-    ];
-    private int $max_file_size = 5242880; // 5MB in bytes
-
-
-    public function __construct()
-    {
-        // Hide default fields and add our customizations
-        add_action('login_enqueue_scripts', array($this, 'registrationStyles'));
-        add_action('register_form', array($this, 'addRegistrationFields'));
-        add_action('login_header', array($this, 'addRegistrationScript'));
-
-        // Handle the registration
-        add_filter('registration_errors', array($this, 'registrationErrorsFilter'), 10, 3);
-        add_action('user_register', array($this, 'saveRegistrationFields'), 999, 2);
-
-        // Modify default registration fields
-        add_action('login_head', array($this, 'modifyRegistrationForm'));
-
-        // Add support for file uploads in the form
-        add_action('register_form', array($this, 'addUploadSupport'));
-
-        add_filter('login_message', array($this, 'loginMessage'), 999, 1);
-
-
-        add_filter('pre_user_login', array($this, 'setUserLogin'), 1);
-        add_filter('pre_user_email', array($this, 'setUserEmail'), 1);
-
-        // Remove the default username requirement
-        remove_filter('registration_errors', 'registration_auth_pass_filter', 10);
-
-        // Add this new filter for registration message
-        add_filter('register_message', array($this, 'customRegisterMessage'));
-        add_filter('wp_login_errors', array($this, 'registrationSuccessMessage'), 10, 2);
-    }
-
-
-    /**
-     * @param string $message
-     *
-     * @return string
-     */
-    public function loginMessage(string $message):string
-    {
-        if (array_key_exists('action', $_GET) && $_GET['action'] == 'register') {
-            if ($this->fromInvite()) {
-                $data = JVB()->routes('invites')->verifyInvitation(sanitize_text_field($_GET['invite']), sanitize_email($_GET['email']));
-                $name = $data->name;
-                $inviters = json_decode($data->inviters, true);
-                $names = [];
-                foreach ($inviters as $inviter) {
-                    $artist = jvbContentFromUser((int)$inviter['user_id']);
-                    $names[] = ($artist['name'] === '') ? $artist['display_name'] : $artist['name'];
-                }
-                $message = (count($names) > 1) ? 'are already here, and have invited you to join in!' : ' is already here, and inivited you to join in!';
-                return '<h2>Join the Scene, '.$name.'</h2>
-                <p style="text-align:center;">'.jvbCommaList($names).$message.'</p>';
-            }
-            if ($this->fromFavourites()) {
-                return '<h2>Join the scene; keep your collection.</h2>';
-            }
-            return '<h2>Join the Scene</h2>';
-        } else {
-            return '<h2>Enter the Scene</h2>';
-        }
-
-    }
-
-    /**
-     * @param string $login
-     *
-     * @return string
-     */
-    public function setUserLogin(string $login):string
-    {
-        $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-        if (!empty($user_type)) {
-            $email_field = $user_type . '_email';
-            if (isset($_POST[$email_field])) {
-                $email = sanitize_email($_POST[$email_field]);
-                if (is_email($email)) {
-                    return $email;
-                }
-            }
-        }
-        return $login;
-    }
-
-    /**
-     * @param string $email
-     *
-     * @return string
-     */
-    public function setUserEmail(string $email):string
-    {
-        $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-        if (!empty($user_type)) {
-            $email_field = $user_type . '_email';
-            if (isset($_POST[$email_field])) {
-                $email = sanitize_email($_POST[$email_field]);
-                if (is_email($email)) {
-                    return $email;
-                }
-            }
-        }
-        return $email;
-    }
-
-    /**
-     * @return void
-     */
-    public function modifyRegistrationForm():void
-    {
-        if (!isset($_GET['action']) || $_GET['action'] !== 'register') {
-            return;
-        }
-
-        ?>
-        <script type="text/javascript">
-            document.addEventListener('DOMContentLoaded', function() {
-                // Hide default fields
-                const defaultFields = document.getElementById('registerform').querySelectorAll('p');
-                defaultFields.forEach(field => {
-                    if (field.querySelector('label[for="user_login"]') ||
-                        field.querySelector('label[for="user_email"]')) {
-                        field.remove();
-                    }
-                });
-
-                // Hide the default registration info text
-                const regInfo = document.querySelector('.message.register');
-                if (regInfo) {
-                    regInfo.style.display = 'none';
-                }
-
-                <?php
-                if ($this->fromInvite()) {
-                    $this->handleArtistInvitation();
-                }
-                ?>
-                // Move submit button to the end of the form
-                const submitButton = document.getElementById('registerform').querySelector('.submit');
-                if (submitButton) {
-                    document.getElementById('registerform').appendChild(submitButton);
-                }
-            });
-        </script>
-
-        <?php
-    }
-
-    /**
-     * @return void
-     */
-    protected function handleArtistInvitation():void
-    {
-        $token = sanitize_text_field($_GET['invite']);
-        $email = sanitize_email($_GET['email']);
-        $data = JVB()->routes('invites')->verifyInvitation($token, $email);
-
-        ?>
-                document.querySelector('input#artist').checked = true;
-                document.querySelector('#artist_first_name').value = '<?=$data->name?>';
-                document.querySelector('#artist_email').value = '<?=$email?>';
-                <?php
-                if ($data->to_shop) {
-                    ?>
-                    document.querySelector('#artist_shop').value = '<?=$data->shop?>';
-                    <?php
-                }
-                ?>
-                let form = document.getElementById('registerform')
-                let input = document.createElement('input');
-                let email = input.cloneNode(true);
-                input.type = 'hidden';
-                input.name = 'invite_token';
-                input.value = '<?= $token ?>';
-                email.type = 'hidden';
-                email.name = 'invite_email';
-                email.value = '<?= $email?>';
-                form.append(input);
-                form.append(email);
-        <?php
-    }
-
-    /**
-     * @return void
-     */
-    public function addUploadSupport():void
-    {
-        // Add enctype to the form for file uploads
-        ?>
-        <script>
-            document.addEventListener('DOMContentLoaded', function() {
-                const form = document.getElementById('registerform');
-                if (form) {
-                    form.enctype = 'multipart/form-data';
-                }
-            });
-        </script>
-        <?php
-    }
-
-    /**
-     * @return void
-     */
-    public function registrationStyles():void
-    {
-        if (isset($_GET['action']) && $_GET['action'] === 'favourites') {
-            ?>
-            <style>
-                .benefits {
-                    background: rgba(255, 0, 128, 0.05);
-                    padding: 1.5rem;
-                    border-radius: 4px;
-                    margin: 1rem 0 2rem;
-                }
-
-                .benefits h3 {
-                    color: #FF0080;
-                    margin: 0 0 1rem;
-                    font-size: 1.1rem;
-                }
-
-                .benefits ul {
-                    margin: 0;
-                    padding-left: 1.5rem;
-                }
-
-                .benefits li {
-                    margin-bottom: 0.5rem;
-                    color: #666;
-                }
-
-                .benefits li:last-child {
-                    margin-bottom: 0;
-                }
-
-                /* Make the form more focused for this flow */
-                .field-group {
-                    max-width: 400px;
-                    margin: 0 auto;
-                }
-            </style>
-            <?php
-        }
-        if (!isset($_GET['action']) || $_GET['action'] !== 'register') {
-            return;
-        }
-        ?>
-        <style>
-            /* Hide the default registration fields initially */
-            #registerform > p:not(.submit) {
-                display: none;
-            }
-
-            /* Registration form specific styles */
-            #registerform {
-                padding: 2rem !important;
-            }
-
-            .registration-intro {
-                text-align: center;
-                margin-bottom: 2rem !important;
-            }
-
-            .registration-intro h2 {
-                margin: 0 0 1rem !important;
-                font-size: 1.5rem !important;
-            }
-
-            .registration-intro p {
-                color: #666 !important;
-                margin: 0 !important;
-            }
-
-            .user-type-section {
-                margin-bottom: 2rem !important;
-            }
-            .user-type-section p {
-                font-size: 1.4rem;
-                line-height: 1.4;
-                font-weight: bolder;
-            }
-
-            .user-type-selection {
-                display: flex !important;
-                gap: 1rem !important;
-                margin-bottom: 1rem !important;
-            }
-
-            .user-type-option {
-                flex: 1 !important;
-                text-align: center !important;
-                padding: 1rem !important;
-                border: 2px solid #ddd !important;
-                border-radius: 4px !important;
-                cursor: pointer !important;
-                transition: all 0.3s ease !important;
-            }
-
-            .user-type-option:hover {
-                border-color: var(--primary) !important;
-                transform: translateY(-2px) !important;
-            }
-
-            .user-type-option.selected {
-                border-color: var(--primary) !important;
-                background: var(--primary) !important;
-                color: white !important;
-            }
-
-            .user-type-option h3 {
-                margin: 0 0 0.5rem !important;
-                font-size: 1.2rem !important;
-            }
-
-            .user-type-option p {
-                margin: 0 !important;
-                font-size: 0.9rem !important;
-            }
-
-            .field-group {
-                display: none;
-                animation: fadeIn 0.3s ease;
-            }
-
-            .field-group.active {
-                display: block !important;
-            }
-
-            @keyframes fadeIn {
-                from { opacity: 0; transform: translateY(10px); }
-                to { opacity: 1; transform: translateY(0); }
-            }
-
-            /* Field styles */
-            .login form textarea,
-            .login form .input,
-            .login select {
-                font-size: 16px !important;
-                padding: 12px !important;
-                border: 2px solid #ddd !important;
-                border-radius: 4px !important;
-                margin: 5px 0 15px !important;
-                width: 100% !important;
-                box-sizing: border-box !important;
-                background: white !important;
-            }
-
-            .login form textarea,
-            .login form .input:focus,
-            .login select:focus {
-                border-color: var(--primary) !important;
-                outline: none !important;
-            }
-
-            .login select {
-                height: auto !important;
-                padding-right: 30px !important;
-                background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='https://www.w3.org/2000/svg'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
-                background-repeat: no-repeat !important;
-                background-position: right 8px center !important;
-                background-size: 16px !important;
-                -webkit-appearance: none !important;
-                -moz-appearance: none !important;
-                appearance: none !important;
-            }
-
-            /* Required field indicator */
-            .required-field::after {
-                content: '*' !important;
-                color: var(--error) !important;
-                margin-left: 4px !important;
-            }
-
-            /* Submit button styling */
-            .wp-core-ui .button-primary {
-                width: 100% !important;
-                margin-top: 1rem !important;
-                text-transform: uppercase !important;
-            }
-
-            /* Error messages */
-            #login_error {
-                border-left-color: var(--error) !important;
-            }
-
-            /* Responsive adjustments */
-            @media screen and (max-width: 480px) {
-                .user-type-selection {
-                    flex-direction: column !important;
-                }
-            }
-
-            /* File upload styling */
-            .file-upload-container {
-                margin-bottom: 1.5rem !important;
-            }
-
-            .file-upload-label {
-                display: block !important;
-                margin-bottom: 0.5rem !important;
-            }
-
-            .file-upload-wrapper {
-                position: relative !important;
-                border: 2px dashed #ddd !important;
-                border-radius: 4px !important;
-                padding: 1.5rem !important;
-                text-align: center !important;
-                transition: all 0.3s ease !important;
-                background: #f9f9f9 !important;
-            }
-
-            .file-upload-wrapper:hover {
-                border-color: var(--primary) !important;
-                background: #fff !important;
-            }
-
-            .file-upload-wrapper input[type="file"] {
-                position: absolute !important;
-                left: 0 !important;
-                top: 0 !important;
-                width: 100% !important;
-                height: 100% !important;
-                opacity: 0 !important;
-                cursor: pointer !important;
-            }
-
-            .file-upload-text {
-                color: #666 !important;
-                margin: 0 !important;
-            }
-
-            .file-upload-text strong {
-                color: var(--primary) !important;
-                text-decoration: underline !important;
-            }
-
-            .file-preview {
-                display: none;
-                margin-top: 1rem !important;
-            }
-
-            .file-preview.active {
-                display: block !important;
-            }
-
-            .file-preview-content {
-                display: flex !important;
-                align-items: center !important;
-                padding: 0.5rem !important;
-                background: #fff !important;
-                border: 1px solid #ddd !important;
-                border-radius: 4px !important;
-            }
-
-            .file-preview-name {
-                flex-grow: 1 !important;
-                margin-right: 1rem !important;
-            }
-
-            .file-preview-remove {
-                background: none !important;
-                border: none !important;
-                color: var(--error) !important;
-                cursor: pointer !important;
-                padding: 0.25rem 0.5rem !important;
-                font-size: 0.9rem !important;
-            }
-
-            .file-error {
-                color: var(--error) !important;
-                font-size: 0.9rem !important;
-                margin-top: 0.5rem !important;
-                display: none;
-            }
-
-            .file-error.active {
-                display: block !important;
-            }
-
-            .user-type-section {
-                display: flex;
-                justify-content: space-between;
-                align-items: stretch;
-                gap: .5rem;
-                flex-wrap: wrap;
-            }
-            .user-type-section input[type=radio] {
-                position: absolute;
-                left: -300vw;
-            }
-            .user-type-section p {
-                width: 100%;
-                max-height: 0;
-                transform: scaleY(0);
-                transform-origin: top;
-                visibility: hidden;
-                transition: max-height var(--timing) var(--function);
-                transition-property: max-height, transform;
-                position: absolute;
-                text-align: center;
-            }
-            .user-type-section input#enthusiast:checked ~ p.enthusiast {
-                max-height: 100%;
-                transform: scaleY(1);
-                visibility: visible;
-                transition: max-height var(--timing) var(--function);
-                transition-property: max-height, transform;
-                position: relative;
-            }
-            .user-type-section input#artist:checked ~ p.artist {
-                max-height: 100%;
-                transform: scaleY(1);
-                visibility: visible;
-                transition: max-height var(--timing) var(--function);
-                transition-property: max-height, transform;
-                position: relative;
-            }
-            .user-type-section input#partner:checked ~ p.partner {
-                max-height: 100%;
-                transform: scaleY(1);
-                visibility: visible;
-                transition: max-height var(--timing) var(--function);
-                transition-property: max-height, transform;
-                position: relative;
-            }
-            .login .user-type-section label:not([for="subscriber"]) {
-                display: flex;
-                flex-direction: column;
-                align-items: center;
-                justify-content: center;
-                cursor: pointer;
-                border: 2px solid transparent;
-                border-radius: 1rem;
-                padding: .5rem;
-                flex: 1;
-                text-align: center;
-            }
-            .login .user-type-section label h4 {
-                font-size: 1.1rem;
-                font-weight: normal;
-            }
-            .login .user-type-section label:hover,
-            .login .user-type-section :checked + label {
-                border-color: #FF0080;
-                /*background-color: #222222!important;*/
-                /*color: #f9f9f9!important;*/
-            }
-        </style>
-        <?php
-    }
-
-    /**
-     * @return void
-     */
-    public function addRegistrationScript():void
-    {
-        if (!isset($_GET['action']) || $_GET['action'] !== 'register') {
-            return;
-        }
-        ?>
-        <script>
-            document.addEventListener('DOMContentLoaded', function() {
-
-                // Initialize user type selection
-                function initUserTypeSelection() {
-                    // Get all radio buttons with name="user_type"
-                    const userTypeRadios = document.querySelectorAll('input[name="user_type"]');
-                    const fieldGroups = document.querySelectorAll('.field-group');
-
-                    userTypeRadios.forEach(radio => {
-                        radio.addEventListener('change', function() {
-                            // Hide all field groups first
-                            fieldGroups.forEach(group => group.classList.remove('active'));
-
-                            // Show the selected field group
-                            const selectedType = this.value;
-                            const targetGroup = document.querySelector(`.field-group[data-type="${selectedType}"]`);
-                            if (targetGroup) {
-                                targetGroup.classList.add('active');
-                            }
-                        });
-                    });
-
-                    // Show initial field group if a radio is already selected (e.g., on form reload)
-                    const checkedRadio = document.querySelector('input[name="user_type"]:checked');
-                    if (checkedRadio) {
-                        const targetGroup = document.querySelector(`.field-group[data-type="${checkedRadio.value}"]`);
-                        if (targetGroup) {
-                            targetGroup.classList.add('active');
-                        }
-                    }
-                }
-
-                // Initialize shop selection
-                function initShopSelection() {
-                    let form = document.getElementById('registerform');
-                    form.addEventListener('change', (e) => {
-                        if(e.target.id === 'artist_shop' || e.target.id === 'artist_city'){
-                            let next = e.target.parentNode.nextElementSibling;
-                            let input = next.querySelector('input');
-
-                            if(e.target.value === 'other'){
-                                next.style.display = 'block';
-                                next.style.animation = 'fadeIn 0.3s ease';
-                                input.required = true;
-                                input.focus();
-                            }else{
-                                input.required = false;
-                                input.value = '';
-                            }
-                        }
-                    });
-                }
-
-                // Initialize file upload handling
-                function initFileUpload() {
-                    const fileInput = document.getElementById('certification_file');
-                    const filePreview = document.querySelector('.file-preview');
-                    const filePreviewName = document.querySelector('.file-preview-name');
-                    const fileError = document.querySelector('.file-error');
-                    const removeButton = document.querySelector('.file-preview-remove');
-
-                    if (!fileInput || !filePreview || !filePreviewName || !fileError || !removeButton) {
-                        return;
-                    }
-
-                    const maxSize = parseInt(fileInput.dataset.maxSize || 5242880);
-
-                    fileInput.addEventListener('change', function(e) {
-                        const file = e.target.files[0];
-                        fileError.classList.remove('active');
-
-                        if (file) {
-                            const validTypes = ['.jpg','.jpeg','.png','.gif','.pdf'];
-                            const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
-
-                            if (!validTypes.includes(fileExtension)) {
-                                showError('Please upload a valid file type (JPG, PNG, GIF, or PDF)');
-                                fileInput.value = '';
-                                return;
-                            }
-
-                            if (file.size > maxSize) {
-                                showError('File size must be less than 5MB');
-                                fileInput.value = '';
-                                return;
-                            }
-
-                            filePreviewName.textContent = file.name;
-                            filePreview.classList.add('active');
-                        } else {
-                            filePreview.classList.remove('active');
-                        }
-                    });
-
-                    removeButton.addEventListener('click', function() {
-                        fileInput.value = '';
-                        filePreview.classList.remove('active');
-                        fileError.classList.remove('active');
-                    });
-
-                    function showError(message) {
-                        fileError.textContent = message;
-                        fileError.classList.add('active');
-                        filePreview.classList.remove('active');
-                    }
-                }
-
-                // Initialize all components
-                initUserTypeSelection();
-                initShopSelection();
-                initFileUpload();
-            });
-        </script>
-        <?php
-    }
-
-    /**
-     * @return void
-     */
-    public function addRegistrationFields():void
-    {
-        // Get list of tattoo shops from your custom post type
-        $shops = get_terms(array(
-            'taxonomy'      => 'jvb_shop',
-            'hide_empty'    => true
-        ));
-
-        // Get list of cities from your taxonomy
-        $cities = get_terms(array(
-            'taxonomy' => 'jvb_city',
-            'hide_empty' => false,
-        ));
-
-        echo '<input type="hidden" name="user_pass" value="' . wp_generate_password() . '">';
-        ?>
-        <div class="registration-intro">
-
-            <p><b>No algorithm.</b>&emsp;<b>No BS.</b>&emsp;<b>Just Art.</b></p>
-            <p>Drop by. Get Lost. Find your next artist.</p>
-
-            <?php
-            if ($this->fromFavourites()) {
-                ?>
-
-                <div class="favourites-login-message">
-                    <ul class="benefits-list">
-                        <li>Save designs you love</li>
-                        <li>Get personalized recommendations</li>
-                        <li>Connect with artists</li>
-                        <li>Build your inspiration collection</li>
-                        <li>Bonus: It's all free!</li>
-                    </ul>
-                </div>
-            <?php
-            }
-            ?>
-        </div>
-        <h3 style="font-size:1rem;font-weight:normal;text-align:center;color:#ff0080;">Choose how you wish to interact with the community:</h3>
-        <div class="user-type-section">
-            <input type="radio" id="subscriber" name="user_type" value="subscriber" required checked>
-            <label for="subscriber"></label>
-            <input type="radio" id="enthusiast" name="user_type" value="enthusiast" required <?= ($this->fromFavourites()) ? 'checked' : '' ?>>
-            <label for="enthusiast"><?=jvbIcon('heart', ['title' =>'Enthusiast', 'size'=>40])?><h4>Enthusiast</h4><p>Start here.</p></label>
-            <input type="radio" id="artist" name="user_type" value="artist" required>
-            <label for="artist"><?=jvbIcon('drop-simple', ['title'=> 'Artist', 'size'=> 40])?><h4>Artist</h4><p>Show your talent.</p></label>
-            <input type="radio" id="partner" name="user_type" value="partner" required>
-            <label for="partner"><?=jvbIcon('currency-circle-dollar', ['title'=>'Partner', 'size' => 40])?><h4>Partner</h4><p>Support the community.</p></label>
-            <p class="enthusiast">Save your favourites. Get notified.</p>
-            <p class="artist">Show off your work.</p>
-            <p class="partner">Support the community.</p>
-        </div>
-
-        <!-- Enthusiast Fields -->
-        <div class="field-group" data-type="enthusiast">
-            <h4>Welcome to the scene.</h4>
-            <p>Sign up with your email to:</p>
-            <ul>
-                <li>Save your favourites for easy access</li>
-                <li>Get notified when your favourite artists add new content</li>
-                <li>Stay in the loop with local flash days and events</li>
-                <li>Discover styles and artists that match your vision</li>
-            </ul>
-            <p>
-                <label for="enthusiast_first_name" class="required-field">First Name</label>
-                <input type="text" id="enthusiast_first_name" name="enthusiast_first_name" class="input">
-            </p>
-            <p>
-                <label for="enthusiast_email" class="required-field">Email</label>
-                <input type="email" id="enthusiast_email" name="enthusiast_email" class="input">
-            </p>
-            <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
-        </div>
-
-        <!-- Artist Fields -->
-        <div class="field-group" data-type="artist">
-            <h4>Welcome to the scene!</h4>
-            <p>We'll start small, with the basics. Before your profile goes live, we need to verify:</p>
-            <ul>
-                <li>you are who you say you are</li>
-                <li>you work at the shop you listed</li>
-                <li>your certification</li>
-            </ul>
-            <p>
-                <label for="artist_first_name" class="required-field">First Name</label>
-                <input type="text" id="artist_first_name" name="artist_first_name" class="input">
-            </p>
-            <p>
-                <label for="artist_last_name" class="required-field">Last Name</label>
-                <input type="text" id="artist_last_name" name="artist_last_name" class="input">
-            </p>
-            <p>
-                <label for="artist_email" class="required-field">Email</label>
-                <input type="email" id="artist_email" name="artist_email" class="input">
-            </p>
-            <p>
-                <label for="artist_shop" class="required-field">Shop</label>
-                <select id="artist_shop" name="artist_shop" class="input">
-                    <option value="">Select a shop</option>
-                    <option value="other">Add New Shop</option>
-                    <?php foreach ($shops as $shop) : ?>
-                        <option value="<?= esc_attr($shop->term_id); ?>"><?= esc_html($shop->name); ?></option>
-                    <?php endforeach; ?>
-                </select>
-            </p>
-            <p id="other_shop_field" style="display: none;">
-                <label for="artist_shop_other" class="required-field">Shop Name</label>
-                <input type="text" id="artist_shop_other" name="artist_shop_other" class="input"
-                       placeholder="Shop name">
-            </p>
-
-            <p>
-                <label for="artist_type" class="required-field">Type</label>
-                <input type="radio" id="type-tattoo-artist" name="artist_type" value="tattoo-artist">
-                <label for="type-tattoo-artist">Tattoo Artist</label>
-                <input type="radio" id="type-piercer" name="artist_type" value="piercer">
-                <label for="type-piercer">Piercer</label>
-                <input type="radio" id="type-other" name="artist_type" value="other">
-                <label for="type-other">Other</label>
-            </p>
-            <p>
-                <label for="artist_city" class="required-field">City</label>
-                <select id="artist_city" name="artist_city" class="input">
-                    <option value="">Select a city</option>
-                    <option value="other">Add New City</option>
-                    <?php foreach ($cities as $city) : ?>
-                        <option value="<?= esc_attr($city->term_id); ?>"><?= esc_html($city->name); ?></option>
-                    <?php endforeach; ?>
-                </select>
-            </p>
-            <p id="other_city_field" style="display: none;">
-                <label for="artist_city_other" class="required-field">City Name</label>
-                <input type="text" id="artist_city_other" name="artist_city_other" class="input"
-                       placeholder="City">
-            </p>
-
-            <div class="file-upload-container">
-                <label class="file-upload-label">Certification or Training Documents</label>
-                <p><i>Optional</i> — If you've been certified in bloodborne pathogen safety, or any other tattoo safety course, pass along your certificate. This just eases the verification process.</p>
-                <div class="file-upload-wrapper">
-                    <input type="file"
-                           name="certification_file"
-                           id="certification_file"
-                           accept=".jpg,.jpeg,.png,.gif,.pdf"
-                           data-max-size="<?= $this->max_file_size; ?>">
-                    <p class="file-upload-text">
-                        <strong>Click to upload</strong> or drag and drop<br>
-                        JPG, PNG, GIF or PDF (max. 5MB)
-                    </p>
-                </div>
-                <div class="file-preview">
-                    <div class="file-preview-content">
-                        <span class="file-preview-name"></span>
-                        <button type="button" class="file-preview-remove">Remove</button>
-                    </div>
-                </div>
-                <div class="file-error"></div>
-            </div>
-            <p>Once you click register:</p>
-            <ul>
-                <li>We'll start looking into your information (usually within 24-48 hours)</li>
-                <li>You'll get a password reset email</li>
-                <li>Upon setting your password, you can start filling in your profile - but it won't go live until we've verified your information.</li>
-            </ul>
-            <p>If you have any questions or concerns - or anything you'd like to follow up on - email us at get@edmonton.ink or message us on <a target="_blank" href="https://www.instagram.com/edmonton.ink/" title="@edmonton.ink on Instagram">Instagram</a>.</p>
-            <div><p><b>BONUS</b>: Everything's free. And always will be. We work with partners chosen by and for the community to keep the lights on.</p></div>
-        </div>
-
-        <!-- Partner Fields -->
-        <div class="field-group" data-type="partner">
-            <h4>Howdy, partner!</h4>
-            <p>We appreciate your interest!</p>
-            <p>edmonton.ink is a great place to showcase what you do, whether you:</p>
-            <ul>
-                <li>provide goods or services that tattoo artists could use</li>
-                <li>provide goods or services that are tattoo adjacent (such as art, merch, etc)</li>
-                <li>provide goods or services that folks who love tattoos could also love</li>
-            </ul>
-
-            <p>We'll start with some basics, then we'll reach out to follow up (usually within 24-48 hours).</p>
-            <p>
-                <label for="partner_name" class="required-field">Contact Name</label>
-                <input type="text" id="partner_name" name="partner_name" class="input">
-            </p>
-            <p>
-                <label for="partner_email" class="required-field">Email</label>
-                <input type="email" id="partner_email" name="partner_email" class="input">
-            </p>
-            <p>
-                <label for="partner_business" class="required-field">Business Name</label>
-                <input type="text" id="partner_business" name="partner_business" class="input">
-            </p>
-            <p>
-                <label for="partner_website">Business Website</label>
-                <input type="url" id="partner_website" name="partner_website" class="input">
-            </p>
-            <p>
-                <label for="partner_description">Why would you be a good fit?</label>
-                <textarea id="partner_description" name="partner_description" rows="8"></textarea>
-            </p>
-            <p><i>Note:</i> — you must have good standing in the tattoo community to stay a partner of edmonton.ink.</p>
-            <p>If we receive multiple requests to terminate a partnership with you from member artists, we reserve the right to cancel your listings.</p>
-        </div>
-
-        <?php
-
-        if ($this->invitation_data) {
-            // Pre-select artist type and populate email
-            ?>
-            <script>
-                document.addEventListener('DOMContentLoaded', function() {
-                    // Auto-select artist radio button
-                    const artistRadio = document.getElementById('artist');
-                    if (artistRadio) {
-                        artistRadio.checked = true;
-                        artistRadio.dispatchEvent(new Event('change'));
-                    }
-
-                    // Pre-fill email
-                    const emailField = document.getElementById('artist_email');
-                    if (emailField) {
-                        emailField.value = '<?= esc_js($this->invitation_data['email']); ?>';
-                        emailField.readOnly = true;
-                    }
-
-                    // Pre-select shop
-                    const shopSelect = document.getElementById('artist_shop');
-                    if (shopSelect) {
-                        shopSelect.value = '<?= esc_js($this->invitation_data['shop_id']); ?>';
-                        shopSelect.readOnly = true;
-                    }
-                });
-            </script>
-            <input type="hidden" name="invitation_token" value="<?= sanitize_text_field($_GET['invite']) ?>">
-            <input type="hidden" name="invitation_email" value="<?= sanitize_email($_GET['email'])?>"
-            <?php
-        }
-    }
-
-    /**
-     * @param WP_Error $errors
-     * @param string $sanitized_user_login
-     * @param string $user_email
-     *
-     * @return WP_Error
-     */
-    public function registrationErrorsFilter(WP_Error $errors, string $sanitized_user_login, string $user_email):WP_Error
-    {
-        error_log('Registration Data: '.print_r($_POST, true));
-        $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : '';
-
-        if (empty($user_type)) {
-            $errors->add('user_type_error', 'Please select your user type.');
-            return $errors;
-        }
-        // Get email based on user type
-        $email_field = $user_type . '_email';
-        $email = isset($_POST[$email_field]) ? sanitize_email($_POST[$email_field]) : '';
-
-        // Remove WordPress's default username error
-        $errors = new WP_Error();
-
-        // If this is an invited artist, validate the invitation
-        $invite = (array_key_exists('invite_token', $_POST)) ? sanitize_text_field($_POST['invite_token']) : false;
-        if ($invite&& $user_type === 'artist') {
-            $handler = JVB()->routes('invites');
-            $invitation = $handler->validateInvitation($invite, sanitize_email($_POST['invite_email']), sanitize_text_field($_POST['role']));
-
-            if (!$invitation) {
-                $errors->add('invalid_invitation', 'Invalid invitation token.');
-            } elseif (strtotime($invitation->expires_at) < current_time('timestamp')) {
-                $errors->add('expired_invitation', 'This invitation has expired.');
-            }
-        }
-
-        // Validate email first
-        if (empty($email)) {
-            $errors->add('email_error', 'Email is required.');
-        } elseif (!is_email($email)) {
-            $errors->add('email_error', 'Please enter a valid email address.');
-        } elseif (email_exists($email)) {
-            $errors->add('email_error', 'This email is already registered.');
-        }
-
-        switch ($user_type) {
-            case 'enthusiast':
-                if (empty($_POST['enthusiast_first_name'])) {
-                    $errors->add('first_name_error', 'First name is required.');
-                }
-                break;
-
-            case 'artist':
-                $required_fields = array(
-                    'artist_first_name' => 'First name',
-                    'artist_last_name' => 'Last name',
-                    'artist_shop' => 'Shop',
-                    'artist_city' => 'City',
-                    'artist_type'    => 'Type',
-                );
-                foreach ($required_fields as $field => $label) {
-                    if (empty($_POST[$field])) {
-                        $errors->add($field . '_error', $label . ' is required.');
-                    }
-                }
-                break;
-
-            case 'partner':
-                $required_fields = array(
-                    'partner_name' => 'Contact name',
-                    'partner_business' => 'Business name'
-                );
-
-                foreach ($required_fields as $field => $label) {
-                    if (empty($_POST[$field])) {
-                        $errors->add($field . '_error', $label . ' is required.');
-                    }
-                }
-                break;
-        }
-
-        if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
-            $file = $_FILES['certification_file'];
-
-            // Validate file type
-            if (!in_array($file['type'], $this->allowed_file_types)) {
-                $errors->add('file_type_error', 'Please upload a valid file type (JPG, PNG, GIF, or PDF)');
-            }
-
-            // Validate file size
-            if ($file['size'] > $this->max_file_size) {
-                $errors->add('file_size_error', 'File size must be less than 5MB');
-            }
-        }
-
-
-        return $errors;
-    }
-
-    /**
-     * @param int $user_id
-     * @param array $userdata
-     *
-     * @return void
-     */
-    public function saveRegistrationFields(int $user_id, array $userdata):void
-    {
-        $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : false;
-        if (!$user_type) {
-            return;
-        }
-        $shop_id = $_POST['shop_id'] ?? false;
-
-        // Set user role based on type
-        $user = new WP_User($user_id);
-
-
-        $caps = JVB()->roles();
-
-        $email = false;
-        $upload_dir = wp_upload_dir();
-        $base_dir = $upload_dir['basedir'];
-        switch ($user_type) {
-            case 'artist':
-                $user->set_role('jvb_artist');
-                $user->remove_role('subscriber');
-
-
-                $email = sanitize_email($_POST['artist_email']);
-                $first = sanitize_text_field($_POST['artist_first_name']);
-                $last = sanitize_text_field($_POST['artist_last_name']);
-                $display_name = $first . ' ' . $last;
-                // Save artist fields
-                $temp = wp_update_user([
-                    'ID'            => $user_id,
-                    'first_name'    => $first,
-                    'last_name'        => $last,
-                    'display_name'    => $display_name
-                ]);
-                $user = get_userdata($temp);
-
-                $link = $caps->addUserLink($user, 'artist');
-                $meta = new MetaManager($link, 'post');
-                $meta->updateValue('first_name', $first);
-                $meta->updateValue('email', $email);
-
-                // If this was an invited artist, handle the invitation
-                if (array_key_exists('invite_token', $_POST)) {
-                    $handler = JVB()->routes('invites');
-                    $handler->acceptInvitation(sanitize_text_field($_POST['invite_token']), sanitize_email($_POST['invite_email']), $user->ID);
-                    $user->add_cap('skip_moderation', true);
-                }
-
-
-                if (absint($_POST['artist_shop']) > 0) {
-                    JVB()->routes('shop')->requestShopAdmission($user_id, absint($_POST['artist_shop']));
-                }
-                if (absint($_POST['artist_city']) >0) {
-                    wp_set_post_terms($link, (int)absint($_POST['artist_city']), BASE.'city');
-                }
-
-                //Create approval request and notify verified users
-                JVB()->routes('approvals')->createArtistApprovalRequest($user_id);
-
-                //Make base directories
-                $artist_dir = $base_dir . '/artists/' . $user_id;
-                wp_mkdir_p($artist_dir);
-                // Directories for all artists
-                wp_mkdir_p($artist_dir . '/artwork');
-                wp_mkdir_p($artist_dir . '/events');
-                // Add a directory for profile images
-                wp_mkdir_p($artist_dir . '/profile');
-
-                // Add a temp directory for uploads in progress
-                wp_mkdir_p($artist_dir . '/temp');
-
-
-                switch ($_POST['artist_type']) {
-                    case 'tattoo-artist':
-                        $caps->setUserAs($user, 'tattoo-artist');
-                        $term = get_term_by('name', 'Tattoo Artists', BASE.'type');
-                        if ($term && !is_wp_error($term)) {
-                            wp_set_post_terms($link, $term->term_id, BASE.'type');
-                        }
-                        wp_mkdir_p($artist_dir . '/tattoos');
-                        break;
-                    case 'piercer':
-                        $caps->setUserAs($user, 'piercer');
-                        $term = get_term_by('name', 'Piercers', BASE.'type');
-                        if ($term && !is_wp_error($term)) {
-                            wp_set_post_terms($link, $term->term_id, BASE.'type');
-                        }
-                        wp_mkdir_p($artist_dir . '/piercings');
-                        break;
-                }
-
-                break;
-            case 'partner':
-                $user->set_role('jvb_partner');
-                $user->remove_role('subscriber');
-                $name = sanitize_text_field($_POST['partner_name']);
-                $email = sanitize_email($_POST['partner_email']);
-
-                $caps->setUserAs($user, 'partner');
-                $link = $caps->addUserLink($user, 'partner');
-                // Save partner fields
-                update_user_meta($user_id, 'contact_name', sanitize_text_field($_POST['partner_name']));
-                update_user_meta($user_id, 'business_name', sanitize_text_field($_POST['partner_business']));
-                update_user_meta($user_id, 'business_website', esc_url_raw($_POST['partner_website']));
-
-                // Create partner base directory
-                $partner_dir = $base_dir . '/partners/' . $user_id;
-                wp_mkdir_p($partner_dir);
-
-                // Partner subdirectories
-                wp_mkdir_p($partner_dir . '/offers');
-                wp_mkdir_p($partner_dir . '/events');
-                wp_mkdir_p($partner_dir . '/profile');
-                wp_mkdir_p($partner_dir . '/temp');
-                break;
-
-            case 'enthusiast':
-                $user->set_role('jvb_enthusiast');
-                $user->remove_role('subscriber');
-                $caps->setUserAs($user, 'enthusiast');
-                $name = sanitize_text_field($_POST['enthusiast_first_name']);
-                $email = sanitize_email($_POST['enthusiast_email']);
-
-                // Save artist fields
-                $temp = wp_update_user([
-                    'ID'            => $user_id,
-                    'first_name'    => $name,
-                    'user_email'    => $email,
-                ]);
-                break;
-            default:
-                break;
-        }
-
-        if (isset($_POST['user_type']) && $_POST['user_type'] === 'artist' && !empty($_FILES['certification_file']['name'])) {
-            $file = $_FILES['certification_file'];
-
-            // Setup upload directory
-            $upload_dir = wp_upload_dir();
-            $user_directory = 'artist-certifications/' . $user_id;
-            $target_dir = $upload_dir['basedir'] . '/' . $user_directory;
-
-            // Create directory if it doesn't exist
-            wp_mkdir_p($target_dir);
-
-            // Generate unique filename
-            $file_extension = pathinfo($file['name'], PATHINFO_EXTENSION);
-            $filename = 'certification-' . time() . '.' . $file_extension;
-            $target_file = $target_dir . '/' . $filename;
-
-            // Move uploaded file
-            if (move_uploaded_file($file['tmp_name'], $target_file)) {
-                // Save file information in user meta
-                update_user_meta($user_id, 'certification_file', array(
-                    'url' => $upload_dir['baseurl'] . '/' . $user_directory . '/' . $filename,
-                    'file' => $target_file,
-                    'type' => $file['type'],
-                    'original_name' => $file['name']
-                ));
-            }
-        }
-
-        if (isset($_GET['list_token']) && !empty($_GET['list_token']) && isset($_GET['email'])) {
-            $token = sanitize_text_field($_GET['list_token']);
-            $email = sanitize_email($_GET['email']);
-
-            // Accept the list invitation for this new user
-            if ($email) {
-                JVB()->routes('favourites')->acceptListInvitation(
-                    $token,
-                    $email,
-                    $user_id
-                );
-            }
-        }
-    }
-
-    /**
-     * @param WP_Error $errors
-     * @param string $redirect_to
-     *
-     * @return WP_Error
-     */
-    public function registrationSuccessMessage(WP_Error $errors, string $redirect_to):WP_Error
-    {
-        if (isset($errors->errors['registered']) && isset($_POST['invitation_token'])) {
-            // Custom message for invited artists
-            $message = "WELCOME ABOARD!<br><br>" .
-                       "Password setup is in your inbox. <br>" .
-                       "Since you were invited by a shop, you can skip the verification wait and start building your profile right away! ♡";
-
-            unset($errors->errors['registered']);
-            $errors->add('registered', $message, 'message');
-        }
-        if (isset($errors->errors['registered'])) {
-            $user_type = isset($_POST['user_type']) ? $_POST['user_type'] : 'user';
-
-            switch ($user_type) {
-                case 'enthusiast':
-                    $message = "YOU'RE IN!<br><br>Check your inbox - we've sent password setup details.<br>Get ready to build your dream artist collection! ♡";
-                    break;
-
-                case 'artist':
-                    $message = "HELL YEAH!<br><br>Password setup is in your inbox. <br>While we verify your info (24-48hrs), you can start building your profile. <br>Just remember - it stays underground until you're cleared. ♡";
-                    break;
-
-                case 'partner':
-                    $message = "ROCK ON!<br><br>Check your inbox - we've sent password setup details.<br>We'll check out your pitch in the next 24-48hrs. <br><br>Meanwhile, you can start prepping your presence - but you won't hit the streets until we give the nod. ♡";
-                    break;
-
-                default:
-                    $message = "YOU'RE ON THE LIST!<br><br>Check your inbox for the next steps. ♡";
-            }
-
-            // Replace the default message
-            unset($errors->errors['registered']);
-            $errors->add('registered', $message, 'message');
-        }
-
-        return $errors;
-    }
-
-    /**
-     * @return bool
-     */
-    protected function fromFavourites():bool
-    {
-        return isset($_GET['type']) && $_GET['type'] === 'favourites';
-    }
-
-    /**
-     * @return bool
-     */
-    protected function fromInvite():bool
-    {
-        return isset($_GET['invite']) && isset($_GET['email']);
-    }
-
-    /**
-     * @param string $message
-     *
-     * @return string
-     */
-    public function customRegisterMessage(string $message):string
-    {
-        return "Join Edmonton's tattoo community";
-    }
-}
-
-// Initialize the registration customizer
-new RegisterManager();
diff --git a/inc/managers/SEO/BreadcrumbManager.php b/inc/managers/SEO/BreadcrumbManager.php
index 0a23c5c..1eecb1b 100644
--- a/inc/managers/SEO/BreadcrumbManager.php
+++ b/inc/managers/SEO/BreadcrumbManager.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\managers\SEO;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\utility\Features;
 use WP_Post;
 use WP_Term;
@@ -18,14 +18,14 @@
  */
 class BreadcrumbManager
 {
-	private CacheManager $cache;
+	private Cache $cache;
 	private static ?self $instance = null;
 
 	private function __construct()
 	{
-		$this->cache = CacheManager::for('breadcrumbs', MONTH_IN_SECONDS)->connectTo('all');
+		$this->cache = Cache::for('breadcrumbs', MONTH_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user');
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 	}
 
@@ -65,16 +65,13 @@
 				break;
 		}
 
-		$crumbs = $this->cache->get($key);
-		if ($crumbs !== false) {
-			return $crumbs;
-		}
-
-		$crumbs = $this->buildCrumbs();
-		$crumbs = apply_filters('jvbBreadcrumbs',$crumbs);
-		$this->cache->set($key, $crumbs);
-
-		return $crumbs;
+		return $this->cache->remember(
+			$key,
+			function() {
+				$crumbs = $this->buildCrumbs();
+				return apply_filters('jvbBreadcrumbs',$crumbs);
+			}
+		);
 	}
 
 	/**
@@ -215,7 +212,7 @@
 	{
 		$url = get_term_link($term->term_id);
 		array_unshift($crumbs, [
-			'name' => $term->name,
+			'name' => html_entity_decode($term->name),
 			'url'  => $url,
 			'id'   => $term->term_id,
 		]);
@@ -349,9 +346,9 @@
 	public function invalidateCache(?int $objectId = null): void
 	{
 		if ($objectId) {
-			$this->cache->delete($objectId);
+			$this->cache->forget($objectId);
 		} else {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 	}
 
@@ -374,7 +371,7 @@
 				}
 			}
 			$crumbs[] = [
-				'name' => $term->name,
+				'name' => html_entity_decode($term->name),
 				'url'   => get_term_link($term, $taxonomy)
 			];
 		}
diff --git a/inc/managers/SEO/SEOAdminPage.php b/inc/managers/SEO/SEOAdminPage.php
index 53ea618..93dea24 100644
--- a/inc/managers/SEO/SEOAdminPage.php
+++ b/inc/managers/SEO/SEOAdminPage.php
@@ -2,7 +2,6 @@
 namespace JVBase\managers\SEO;
 
 use JVBase\managers\AdminPages;
-use JVBase\managers\CacheManager;
 use JVBase\meta\MetaForm;
 use JVBase\ui\Tabs;
 
diff --git a/inc/managers/SEO/SchemaOutputManager.php b/inc/managers/SEO/SchemaOutputManager.php
index 140716b..cd8d0e5 100644
--- a/inc/managers/SEO/SchemaOutputManager.php
+++ b/inc/managers/SEO/SchemaOutputManager.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\managers\SEO;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 use WP_Term;
 use WP_User;
@@ -16,14 +16,14 @@
  * Integrates with The SEO Framework, letting it handle defaults
  * while we override with our configured templates.
  *
- * Now with integrated caching via CacheManager for performance.
+ * Now with integrated caching via Cache for performance.
  */
 class SchemaOutputManager
 {
 	private ConfigManager $config;
 	private SchemaBuilder $registry;
 	private ?TemplateResolver $resolver = null;
-	private CacheManager $cache;
+	private Cache $cache;
 	private array $pseudoTypes = [
 		'BeforeAfter',
 	];
@@ -31,12 +31,10 @@
 	public function __construct()
 	{
 		$this->registry = SchemaBuilder::getInstance();
-		$this->cache = CacheManager::for('schema');
-
-		// Register cache connections
-		$this->cache->connectTo('post', 'id');
-		$this->cache->connectTo('taxonomy', 'id');
-		$this->cache->connectTo('user', 'id');
+		$this->cache = Cache::for('schema')
+			->connect('post',true)
+			->connect('taxonomy',true)
+			->connect('user',true);
 
 		// Hook into TSF for meta
 		add_filter('the_seo_framework_title_from_generation', [$this, 'filterTitle'], 10, 2);
diff --git a/inc/managers/SEO/TemplateResolver.php b/inc/managers/SEO/TemplateResolver.php
index 1090f9c..8b5c57d 100644
--- a/inc/managers/SEO/TemplateResolver.php
+++ b/inc/managers/SEO/TemplateResolver.php
@@ -607,8 +607,8 @@
 		} elseif ($this->objectType === 'term' && $this->objectId) {
 			$term = get_term($this->objectId);
 			if ($term && !is_wp_error($term)) {
-				$this->context['term_name'] = $term->name;
-				$this->context['term_description'] = $term->description;
+				$this->context['term_name'] = html_entity_decode($term->name);
+				$this->context['term_description'] = wptexturize($term->description);
 				$this->context['taxonomy'] = $term->taxonomy;
 			}
 		} elseif ($this->objectType === 'user' && $this->objectId) {
diff --git a/inc/managers/SEO/_edmonotonink.php b/inc/managers/SEO/_edmonotonink.php
index c4a356a..14120eb 100644
--- a/inc/managers/SEO/_edmonotonink.php
+++ b/inc/managers/SEO/_edmonotonink.php
@@ -463,7 +463,7 @@
 						foreach ((array)$similar as $similar_id) {
 							$term = get_term($similar_id, BASE . 'theme');
 							if ($term && !is_wp_error($term)) {
-								$similar_names[] = $term->name;
+								$similar_names[] = html_entity_decode($term->name);
 							}
 						}
 						if (!empty($similar_names)) {
@@ -524,7 +524,7 @@
 					$term = get_term($term_id);
 					return [
 						'@type' => 'PostalAddress',
-						'addressLocality' => $term->name,
+						'addressLocality' => html_entity_decode($term->name),
 						'addressRegion' => 'Alberta',
 						'addressCountry' => 'CA'
 					];
diff --git a/inc/managers/SEOMetaManager.php b/inc/managers/SEOMetaManager.php
index 44d1399..5ccbb0e 100644
--- a/inc/managers/SEOMetaManager.php
+++ b/inc/managers/SEOMetaManager.php
@@ -174,7 +174,7 @@
                 return $this->getCityTitle($term);
 
             default:
-                return $term->name;
+                return html_entity_decode($term->name);
         }
     }
 
@@ -556,7 +556,7 @@
             }
         }
 
-        $length = strlen($term->name) + strlen($city);
+        $length = strlen(html_entity_decode($term->name)) + strlen($city);
 
         $title = match (true) {
             $length < 36 => $city . '\s Best Tattoo Studios',
@@ -565,7 +565,8 @@
             $length < 46 => $city . ' Tattoo Shop',
             default => 'Tattoo Shop: ',
         };
-        return "{$term->name} | {$title}";
+		$name = html_entity_decode($term->name);
+        return "{$name} | {$title}";
     }
 
     /**
@@ -613,7 +614,7 @@
         }
 
         // Build description
-        $description = "{$term->name}";
+        $description = html_entity_decode($term->name);
 
         if (!empty($established)) {
             $description .= " has been slinging ink in {$city} since {$established}";
@@ -638,7 +639,8 @@
      */
     protected function getStyleTitle(WP_Term $term):string
     {
-        return "Edmonton's Best {$term->name} Tattoo Artists";
+		$name = html_entity_decode($term->name);
+        return "Edmonton's Best {$name} Tattoo Artists";
     }
 
     /**
@@ -674,7 +676,8 @@
         }
 
         // Build description
-        $description = "{$term->name}{$alt_name_text} is a distinctive tattoo style";
+		$name = html_entity_decode($term->name);
+        $description = "{$name}{$alt_name_text} is a distinctive tattoo style";
 
         if (!empty($characteristics)) {
             $stripped = wp_strip_all_tags($characteristics);
@@ -685,7 +688,8 @@
             }
         }
 
-        $description .= " Find Edmonton artists specializing in {$term->name} tattoos.";
+		$name = html_entity_decode($term->name);
+        $description .= " Find Edmonton artists specializing in {$name} tattoos.";
 
         return $description;
     }
@@ -698,7 +702,8 @@
      */
     protected function getThemeTitle(WP_Term $term):string
     {
-        return "Edmonton's Best {$term->name} Tattoos";
+		$name = html_entity_decode($term->name);
+        return "Edmonton's Best {$name} Tattoos";
     }
 
     /**
@@ -734,15 +739,16 @@
         }
 
         // Build description
-        $description = "Explore {$term->name} tattoos";
+		$name = html_entity_decode($term->name);
+        $description = "Explore {$name} tattoos";
 
 
         $description .= ", a popular motif in Edmonton's tattoo scene.";
 
 
         $description .= $similar_text;
-
-        $description .= " Find artists specializing in {$term->name} tattoos.";
+		$name = html_entity_decode($term->name);
+        $description .= " Find artists specializing in {$name} tattoos.";
 
         return $description;
     }
@@ -755,7 +761,8 @@
      */
     protected function getCityTitle(WP_Term $term):string
     {
-        return "{$term->name} Tattoo Artists & Shops | edmonton.ink";
+		$name = html_entity_decode($term->name);
+        return "{$name} Tattoo Artists & Shops | edmonton.ink";
     }
 
     /**
@@ -798,7 +805,8 @@
         }
 
         // Build description
-        $description = "Discover {$term->name}'s vibrant tattoo scene";
+		$name = html_entity_decode($term->name);
+        $description = "Discover {$name}'s vibrant tattoo scene";
 
         if ($shop_count > 0 || $artist_count > 0) {
             $description .= " featuring";
diff --git a/inc/managers/SchemaManager.php b/inc/managers/SchemaManager.php
index 1ba6204..5242d30 100644
--- a/inc/managers/SchemaManager.php
+++ b/inc/managers/SchemaManager.php
@@ -365,7 +365,7 @@
             [
                 '@type'             => 'LocalBusiness',
                 '@id'               => $permalink . '#organization',
-                'name'              => $term->name,
+                'name'              => html_entity_decode($term->name),
                 'description'       => $meta->getValue('short_bio') ?: $term->description,
                 'url'               => $permalink,
                 'priceRange'        => '$$', // Default price range
@@ -976,7 +976,7 @@
             [
                 '@type' => 'CreativeWork',
                 '@id' => $permalink . '#style',
-                'name' => $term->name,
+                'name' => html_entity_decode($term->name),
                 'description' => $meta->getValue('characteristics') ?: $term->description,
                 'url' => $permalink,
                 'mainEntityOfPage' => [
@@ -1009,7 +1009,7 @@
         $schema = [
             '@type' => 'CreativeWork',
             '@id' => $permalink . '#theme',
-            'name' => $term->name,
+            'name' => html_entity_decode($term->name),
             'description' => $meta->getValue('description') ?: $term->description,
             'url' => $permalink,
             'mainEntityOfPage' => [
diff --git a/inc/managers/ScriptLoader.php b/inc/managers/ScriptLoader.php
index 8a28ebb..69a4069 100644
--- a/inc/managers/ScriptLoader.php
+++ b/inc/managers/ScriptLoader.php
@@ -2,7 +2,7 @@
 add_action('init', 'jvbRegisterScripts', 5);
 
 function jvbRegisterScripts() {
-	$version = '1.1.23';
+	$version = '1.1.27';
 	$strategy = [
 		'strategy'	=> 'defer',
 		'in_footer'	=> true
diff --git a/inc/managers/TaxonomyRelationships.php b/inc/managers/TaxonomyRelationships.php
index 2f466a7..2becd42 100644
--- a/inc/managers/TaxonomyRelationships.php
+++ b/inc/managers/TaxonomyRelationships.php
@@ -1,8 +1,7 @@
 <?php
 namespace JVBase\managers;
 
-use JVBase\JVB;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use WP_Error;
 use WP_Post;
 
@@ -20,7 +19,7 @@
     {
         global $wpdb;
         $this->table_name = $wpdb->prefix . BASE.'taxonomy_relationships';
-        $this->cache = CacheManager::for('term_relationship', WEEK_IN_SECONDS);
+        $this->cache = Cache::for('term_relationship', WEEK_IN_SECONDS);
 
         // Ensure the table exists
 //        $this->create_table_if_not_exists();
@@ -64,7 +63,6 @@
 		if (in_array($post_type, jvbIgnoredPostTypes())) {
 			return;
 		}
-        $this->cache->invalidate();
         // Get all taxonomies for this post type
         $taxonomies = get_object_taxonomies($post_type);
 
@@ -339,7 +337,7 @@
      */
     public function rebuildAllRelationships():bool
     {
-        $this->cache->invalidate();
+        $this->cache->flush();
         global $wpdb;
 
         // Clear existing relationships
@@ -371,7 +369,7 @@
             ]
         );
 
-        $this->cache->invalidate();
+        $this->cache->flush();
 
         return true;
     }
@@ -449,6 +447,5 @@
             $term_id,
             $term_id
         ));
-        $this->cache->invalidate();
     }
 }
diff --git a/inc/managers/UmamiMetrics.php b/inc/managers/UmamiMetrics.php
deleted file mode 100644
index d88df16..0000000
--- a/inc/managers/UmamiMetrics.php
+++ /dev/null
@@ -1,1785 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use JVBase\JVB;
-use JVBase\managers\CacheManager;
-use wpdb;
-use WP_Error;
-use Exception;
-
-if (!defined('ABSPATH')) {
-    exit; // Exit if accessed directly
-}
-
-/**
- * Handles integration with Umami.js analytics to collect and provide metrics
- */
-class UmamiMetrics
-{
-    protected wpdb $wpdb;
-    protected string $api_url = 'https://cloud.umami.is/api';
-    protected string $website_id;
-    protected string $events_table;
-    protected string $metrics_table;
-    protected CacheManager $cache;
-
-    /**
-     * Constructor
-     */
-    public function __construct()
-    {
-        global $wpdb;
-        $this->wpdb = $wpdb;
-        $this->events_table = $wpdb->prefix . BASE . 'umami_events';
-        $this->metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
-
-        // Get Umami website ID from options
-        $this->website_id = get_option('jvb_umami_website_id', UMAMI_WEBSITE_ID);
-
-        // Initialize cache manager
-        $this->cache = CacheManager::for('umami_metrics', DAY_IN_SECONDS);
-
-        // Register hooks
-        add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']);
-    }
-
-    /**
-     * Get authentication token for Umami API
-     *
-     * @return string|WP_Error Token or error
-     */
-    protected function getAuthToken():string|WP_Error
-    {
-        // Check if we have a cached token
-        $token = get_transient('jvb_umami_auth_token');
-        if ($token) {
-            return $token;
-        }
-
-        // Get decrypted credentials
-        $credentials = $this->getUmamiCredentials();
-        if (!$credentials) {
-            JVB()->error()->log(
-                'umami',
-                'Missing or invalid Umami API credentials',
-                [],
-                'error'
-            );
-            return new WP_Error('missing_credentials', 'Umami API credentials not configured');
-        }
-
-        // Authenticate with Umami
-        $response = wp_remote_post($this->api_url . '/auth/login', [
-            'body' => json_encode([
-                'username' => $credentials['username'],
-                'password' => $credentials['password']
-            ]),
-            'headers' => [
-                'Content-Type' => 'application/json'
-            ]
-        ]);
-
-        if (is_wp_error($response)) {
-            JVB()->error()->log(
-                'umami',
-                'Failed to authenticate with Umami API: ' . $response->get_error_message(),
-                [],
-                'error'
-            );
-            return $response;
-        }
-
-        $data = json_decode(wp_remote_retrieve_body($response), true);
-
-        if (empty($data['token'])) {
-            JVB()->error()->log(
-                'umami',
-                'Invalid response from Umami API',
-                ['response' => wp_remote_retrieve_body($response)],
-                'error'
-            );
-            return new WP_Error('auth_failed', 'Failed to get auth token from Umami API');
-        }
-
-        // Cache token for 23 hours (tokens usually expire after 24 hours)
-        set_transient('jvb_umami_auth_token', $data['token'], 23 * HOUR_IN_SECONDS);
-
-        return $data['token'];
-    }
-
-    /**
-     * Save Umami API credentials
-     *
-     * @param string $username Umami username
-     * @param string $password Umami password
-     * @return bool Success or failure
-     */
-    public function saveUmamiCredentials(string $username, string $password):bool
-    {
-        // Encrypt sensitive data
-        $encrypted_username = $this->encryptData($username);
-        $encrypted_password = $this->encryptData($password);
-
-        // Store encrypted data
-        $success1 = update_option('jvb_umami_username_encrypted', $encrypted_username);
-        $success2 = update_option('jvb_umami_password_encrypted', $encrypted_password);
-
-        return $success1 && $success2;
-    }
-
-    /**
-     * Get Umami API credentials
-     *
-     * @return array|false Credentials or false on failure
-     */
-    protected function getUmamiCredentials():array|false
-    {
-        // Get encrypted credentials
-        $encrypted_username = get_option('jvb_umami_username_encrypted');
-        $encrypted_password = get_option('jvb_umami_password_encrypted');
-
-        if (!$encrypted_username || !$encrypted_password) {
-            return false;
-        }
-
-        // Decrypt
-        $username = $this->decryptData($encrypted_username);
-        $password = $this->decryptData($encrypted_password);
-
-        if (!$username || !$password) {
-            return false;
-        }
-
-        return [
-            'username' => $username,
-            'password' => $password
-        ];
-    }
-
-    /**
-     * Securely encrypt sensitive data
-     *
-     * @param string $data Data to encrypt
-     * @return string Encrypted data
-     */
-    public function encryptData(string $data):string
-    {
-        // Get or generate encryption key
-        $encryption_key = $this->getEncryptionKey();
-
-        // Generate a random initialization vector
-        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
-
-        // Encrypt the data
-        $encrypted = openssl_encrypt($data, 'aes-256-cbc', $encryption_key, 0, $iv);
-
-        // Combine IV and encrypted data for storage
-        return base64_encode($iv . $encrypted);
-    }
-
-    /**
-     * Decrypt sensitive data
-     *
-     * @param string $encrypted_data Encrypted data
-     * @return string|bool Decrypted data or false on failure
-     */
-    public function decryptData(string $encrypted_data):string|bool
-    {
-        // Get encryption key
-        $encryption_key = $this->getEncryptionKey();
-
-        // Decode from base64
-        $data = base64_decode($encrypted_data);
-        if (!$data) {
-            return false;
-        }
-
-        // Extract IV and encrypted data
-        $iv_length = openssl_cipher_iv_length('aes-256-cbc');
-        $iv = substr($data, 0, $iv_length);
-        $encrypted = substr($data, $iv_length);
-
-        // Decrypt
-        return openssl_decrypt($encrypted, 'aes-256-cbc', $encryption_key, 0, $iv);
-    }
-
-    /**
-     * Get or generate the encryption key
-     *
-     * @return string Encryption key
-     */
-    protected function getEncryptionKey():string
-    {
-        // Try to get existing key
-        $key = get_option('jvb_encryption_key');
-
-        // If no key exists, generate one and store it
-        if (!$key) {
-            // Generate a strong 32-byte key
-            $key = bin2hex(openssl_random_pseudo_bytes(32));
-
-            // Store key in WordPress options
-            update_option('jvb_encryption_key', $key, true);
-        }
-
-        return $key;
-    }
-
-    /**
-     * Fetch data from Umami API
-     *
-     * @param string $endpoint API endpoint
-     * @param array $params Query parameters
-     * @return array|WP_Error Response data or error
-     */
-    protected function fetchFromApi(string $endpoint, array $params = []):array|WP_Error
-    {
-        $token = $this->getAuthToken();
-        if (is_wp_error($token)) {
-            return $token;
-        }
-
-        $url = $this->api_url . $endpoint;
-        if (!empty($params)) {
-            $url = add_query_arg($params, $url);
-        }
-
-        $response = wp_remote_get($url, [
-            'headers' => [
-                'Authorization' => 'Bearer ' . $token
-            ]
-        ]);
-
-        if (is_wp_error($response)) {
-            JVB()->error()->log(
-                'umami',
-                'Error fetching from Umami API: ' . $response->get_error_message(),
-                ['endpoint' => $endpoint, 'params' => $params],
-                'error'
-            );
-            return $response;
-        }
-
-        $data = json_decode(wp_remote_retrieve_body($response), true);
-
-        if (empty($data)) {
-            JVB()->error()->log(
-                'umami',
-                'Empty response from Umami API',
-                ['endpoint' => $endpoint, 'response' => wp_remote_retrieve_body($response)],
-                'warning'
-            );
-            return new WP_Error('empty_response', 'Empty response from Umami API');
-        }
-
-        return $data;
-    }
-
-    /**
-     * Collect daily analytics data from Umami and store in database
-     *
-     * @param string|null $date Optional date to collect (defaults to yesterday)
-     * @return array Collection results
-     */
-    public function collectDailyData(string|null $date = null):array
-    {
-        // Default to yesterday if no date provided
-        if (empty($date)) {
-            $date = date('Y-m-d', strtotime('-1 day'));
-        }
-
-        $start_time = microtime(true);
-        $results = [
-            'date' => $date,
-            'events_collected' => 0,
-            'metrics_updated' => 0,
-            'errors' => []
-        ];
-
-        try {
-            // Fetch custom events from Umami
-            $events_data = $this->fetchCustomEvents($date);
-
-            if (is_wp_error($events_data)) {
-                $results['errors'][] = 'Failed to fetch custom events: ' . $events_data->get_error_message();
-                return $results;
-            }
-
-            // Process and store events
-            $events_stored = $this->storeEvents($events_data, $date);
-            $results['events_collected'] = count($events_stored);
-
-            // Generate aggregated metrics
-            $metrics_created = $this->generateDailyMetrics($date);
-            $results['metrics_updated'] = $metrics_created;
-
-            // Log success
-            $duration = round(microtime(true) - $start_time, 2);
-            $results['duration'] = $duration;
-
-            JVB()->error()->log(
-                'umami',
-                'Successfully collected Umami data',
-                [
-                    'date' => $date,
-                    'events' => count($events_stored),
-                    'metrics' => $metrics_created,
-                    'duration' => $duration
-                ],
-                'info'
-            );
-
-            // Clear cache for the processed date
-            $this->cache->invalidate();
-        } catch (Exception $e) {
-            $results['errors'][] = 'Exception during data collection: ' . $e->getMessage();
-
-            JVB()->error()->log(
-                'umami',
-                'Exception during Umami data collection: ' . $e->getMessage(),
-                ['date' => $date],
-                'error'
-            );
-        }
-
-        return $results;
-    }
-
-    /**
-     * Fetch custom events from Umami
-     *
-     * @param string $date Date to fetch in YYYY-MM-DD format
-     * @return array|WP_Error Events data or error
-     */
-    protected function fetchCustomEvents(string $date):array|WP_Error
-    {
-        // Calculate start and end timestamps for the specified date
-        $start_time = strtotime($date . ' 00:00:00');
-        $end_time = strtotime($date . ' 23:59:59');
-
-        return $this->fetchFromApi('/websites/' . $this->website_id . '/events', [
-            'startAt' => $start_time,
-            'endAt' => $end_time,
-            'unit' => 'day'
-        ]);
-    }
-
-    /**
-     * Store events data in the database
-     *
-     * @param array $events_data Events from Umami API
-     * @param string $date Date of events
-     * @return array Stored event IDs
-     */
-    protected function storeEvents(array $events_data, string $date):array
-    {
-        $stored_ids = [];
-
-        // Start transaction
-        $this->wpdb->query('START TRANSACTION');
-
-        try {
-            foreach ($events_data as $event) {
-                // Extract data from the event
-                $event_name = $event['event_name'] ?? '';
-                $event_type = '';
-                $user_id = null;
-                $content_id = null;
-                $content_type = null;
-                $source_id = null;
-                $source_type = null;
-                $owner_id = null;
-                $owner_type = null;
-                $referrer = null;
-                $metadata = [];
-
-                // Process event data properties
-                foreach ($event['event_data'] ?? [] as $key => $value) {
-                    switch ($key) {
-                        case 'type':
-                            $event_type = $value;
-                            break;
-                        case 'user-id':
-                            $user_id = (int)$value;
-                            break;
-                        case 'id':
-                            $content_id = (int)$value;
-                            break;
-                        case 'content-type':
-                            $content_type = $value;
-                            break;
-                        case 'source-id':
-                            $source_id = (int)$value;
-                            break;
-                        case 'source-type':
-                            $source_type = $value;
-                            break;
-                        case 'owner-id':
-                            $owner_id = (int)$value;
-                            break;
-                        case 'owner-type':
-                            $owner_type = $value;
-                            break;
-                        case 'from':
-                            $referrer = $value;
-                            break;
-                        default:
-                            // Store any other data as metadata
-                            if ( str_starts_with( $key, 'meta-' ) ) {
-                                $meta_key = str_replace('meta-', '', $key);
-                                $metadata[$meta_key] = $value;
-                            }
-                    }
-                }
-
-                // Insert event into database
-                $result = $this->wpdb->insert(
-                    $this->events_table,
-                    [
-                        'date' => $date,
-                        'timestamp' => date('Y-m-d H:i:s', $event['created_at'] ?? time()),
-                        'event' => $event_name,
-                        'event_type' => $event_type,
-                        'user_id' => $user_id,
-                        'content_id' => $content_id,
-                        'content_type' => $content_type,
-                        'source_id' => $source_id,
-                        'source_type' => $source_type,
-                        'owner_id' => $owner_id,
-                        'owner_type' => $owner_type,
-                        'referrer' => $referrer,
-                        'metadata' => !empty($metadata) ? json_encode($metadata) : null
-                    ]
-                );
-
-                if ($result) {
-                    $stored_ids[] = $this->wpdb->insert_id;
-                }
-            }
-
-            // Commit transaction
-            $this->wpdb->query('COMMIT');
-
-            return $stored_ids;
-        } catch (Exception $e) {
-            // Rollback on error
-            $this->wpdb->query('ROLLBACK');
-
-            JVB()->error()->log(
-                'umami',
-                'Error storing Umami events: ' . $e->getMessage(),
-                ['date' => $date],
-                'error'
-            );
-
-            throw $e;
-        }
-    }
-
-    /**
-     * Generate daily aggregated metrics for all users
-     *
-     * @param string $date Date to generate metrics for
-     * @return int Number of metrics records created/updated
-     */
-    protected function generateDailyMetrics(string $date):int
-    {
-        // Start transaction
-        $this->wpdb->query('START TRANSACTION');
-
-        try {
-            $count = 0;
-
-            // First, get all users who had activity on this date
-            $active_users = $this->wpdb->get_col($this->wpdb->prepare(
-                "SELECT DISTINCT owner_id FROM {$this->events_table}
-                 WHERE date = %s AND owner_id IS NOT NULL",
-                $date
-            ));
-
-            // Process metrics for each user
-            foreach ($active_users as $user_id) {
-                if ($this->generateUserMetrics($user_id, $date)) {
-                    $count++;
-                }
-            }
-
-            // Also generate metrics for all shops
-            $active_shops = $this->wpdb->get_col($this->wpdb->prepare(
-                "SELECT DISTINCT content_id FROM {$this->events_table}
-                 WHERE date = %s AND event = 'view_shop' AND content_id IS NOT NULL",
-                $date
-            ));
-
-            foreach ($active_shops as $shop_id) {
-                if ($this->generateShopMetrics($shop_id, $date)) {
-                    $count++;
-                }
-            }
-
-            // Commit transaction
-            $this->wpdb->query('COMMIT');
-
-            return $count;
-        } catch (Exception $e) {
-            // Rollback on error
-            $this->wpdb->query('ROLLBACK');
-
-            JVB()->error()->log(
-                'umami',
-                'Error generating metrics: ' . $e->getMessage(),
-                ['date' => $date],
-                'error'
-            );
-
-            throw $e;
-        }
-    }
-
-    /**
-     * Generate metrics for a specific user
-     *
-     * @param int $user_id User ID
-     * @param string $date Date to generate metrics for
-     * @return bool Success or failure
-     */
-    protected function generateUserMetrics(int $user_id, string $date):bool
-    {
-        // Get the artist profile ID for this user
-        $artist_id = get_user_meta($user_id, BASE . 'link', true);
-        if (!$artist_id) {
-            return false;
-        }
-
-        // Calculate metrics
-        $metrics = [
-            'profile_view_count' => $this->countEvents($date, 'view_profile', $artist_id, 'content_id'),
-            'feed_view_count' => $this->countEvents($date, 'view_feed', $artist_id, 'owner_id'),
-            'taxonomy_view_count' => $this->countEvents($date, 'view_taxonomy', $artist_id, 'owner_id'),
-            'shop_view_count' => $this->countEvents($date, 'view_shop', $artist_id, 'owner_id'),
-            'favourite_count' => $this->countEvents($date, 'toggle_favourite', $artist_id, 'content_id', [
-                'metadata_condition' => "JSON_EXTRACT(metadata, '$.action') = 'add'"
-            ]),
-            'upvote_count' => $this->countEvents($date, 'vote', $artist_id, 'content_id', [
-                'metadata_condition' => "JSON_EXTRACT(metadata, '$.vote') = 'up'"
-            ]),
-            'downvote_count' => $this->countEvents($date, 'vote', $artist_id, 'content_id', [
-                'metadata_condition' => "JSON_EXTRACT(metadata, '$.vote') = 'down'"
-            ])
-        ];
-
-        // Calculate total view count
-        $metrics['total_view_count'] = $metrics['profile_view_count'] +
-                                       $metrics['feed_view_count'] +
-                                       $metrics['taxonomy_view_count'] +
-                                       $metrics['shop_view_count'];
-
-        // Calculate karma
-        $metrics['karma'] = $metrics['upvote_count'] - $metrics['downvote_count'];
-
-        // Get top content
-        $metrics['top_content'] = $this->getTopContent($date, $user_id);
-
-        // Get source breakdown
-        $metrics['source_breakdown'] = $this->getSourceBreakdown($date, $artist_id);
-
-        // Get top favourites
-        $metrics['top_favourites'] = $this->getTopFavourites($date, $user_id);
-
-        // Calculate conversion rates
-        if ($metrics['total_view_count'] > 0) {
-            $metrics['favourite_conversion_rate'] = round($metrics['favourite_count'] / $metrics['total_view_count'], 4);
-        } else {
-            $metrics['favourite_conversion_rate'] = 0;
-        }
-
-        // Check if record exists for this date and user
-        $exists = $this->wpdb->get_var($this->wpdb->prepare(
-            "SELECT id FROM {$this->metrics_table}
-             WHERE date = %s AND user_id = %d",
-            $date,
-            $user_id
-        ));
-
-        if ($exists) {
-            // Update existing record
-            $update_data = [
-                'profile_view_count' => $metrics['profile_view_count'],
-                'feed_view_count' => $metrics['feed_view_count'],
-                'taxonomy_view_count' => $metrics['taxonomy_view_count'],
-                'shop_view_count' => $metrics['shop_view_count'],
-                'total_view_count' => $metrics['total_view_count'],
-                'favourite_count' => $metrics['favourite_count'],
-                'upvote_count' => $metrics['upvote_count'],
-                'downvote_count' => $metrics['downvote_count'],
-                'karma' => $metrics['karma'],
-                'favourite_conversion_rate' => $metrics['favourite_conversion_rate'],
-                'top_content' => json_encode($metrics['top_content']),
-                'source_breakdown' => json_encode($metrics['source_breakdown']),
-                'top_favourites' => json_encode($metrics['top_favourites'])
-            ];
-
-            $result = $this->wpdb->update(
-                $this->metrics_table,
-                $update_data,
-                [
-                    'date' => $date,
-                    'user_id' => $user_id
-                ]
-            );
-        } else {
-            // Insert new record
-            $insert_data = [
-                'date' => $date,
-                'user_id' => $user_id,
-                'profile_view_count' => $metrics['profile_view_count'],
-                'feed_view_count' => $metrics['feed_view_count'],
-                'taxonomy_view_count' => $metrics['taxonomy_view_count'],
-                'shop_view_count' => $metrics['shop_view_count'],
-                'total_view_count' => $metrics['total_view_count'],
-                'favourite_count' => $metrics['favourite_count'],
-                'upvote_count' => $metrics['upvote_count'],
-                'downvote_count' => $metrics['downvote_count'],
-                'karma' => $metrics['karma'],
-                'favourite_conversion_rate' => $metrics['favourite_conversion_rate'],
-                'top_content' => json_encode($metrics['top_content']),
-                'source_breakdown' => json_encode($metrics['source_breakdown']),
-                'top_favourites' => json_encode($metrics['top_favourites'])
-            ];
-
-            $result = $this->wpdb->insert($this->metrics_table, $insert_data);
-        }
-
-        return $result !== false;
-    }
-
-    /**
-     * Generate metrics for a specific shop
-     *
-     * @param int $shop_id Shop ID (term_id)
-     * @param string $date Date to generate metrics for
-     * @return bool Success or failure
-     */
-    protected function generateShopMetrics(int $shop_id, string $date):bool
-    {
-        // For now, we're just counting total views
-        $view_count = $this->countEvents($date, 'view_shop', $shop_id, 'content_id');
-
-        // Store in a separate shop metrics table or however you prefer
-        // This is a placeholder for future implementation
-
-        return true;
-    }
-
-    /**
-     * Count events matching specific criteria
-     *
-     * @param string $date Date to query
-     * @param string $event Event name
-     * @param int $id ID to match
-     * @param string $id_field Field to match ID against
-     * @param array $extra_conditions Additional conditions
-     * @return int Count of matching events
-     */
-    protected function countEvents(string $date, string $event, int $id, string $id_field, array $extra_conditions = []):int
-    {
-        $query = $this->wpdb->prepare(
-            "SELECT COUNT(*) FROM {$this->events_table}
-             WHERE date = %s AND event = %s AND $id_field = %d",
-            $date,
-            $event,
-            $id
-        );
-
-        // Add additional conditions
-        if (!empty($extra_conditions['metadata_condition'])) {
-            $query .= " AND " . $extra_conditions['metadata_condition'];
-        }
-
-        return (int)$this->wpdb->get_var($query);
-    }
-
-    /**
-     * Get top content for a user
-     *
-     * @param string $date Date to query
-     * @param int $user_id User ID
-     * @return array Top content by content type
-     */
-    protected function getTopContent(string $date, int $user_id):array
-    {
-        $top_content = [];
-
-        // Get content types for this user
-        $content_types = $this->wpdb->get_col($this->wpdb->prepare(
-            "SELECT DISTINCT content_type FROM {$this->events_table}
-             WHERE date = %s AND owner_id = %d AND content_type IS NOT NULL",
-            $date,
-            $user_id
-        ));
-
-        foreach ($content_types as $type) {
-            // Get top content items for this type
-            $items = $this->wpdb->get_results($this->wpdb->prepare(
-                "SELECT content_id, COUNT(*) as view_count
-                 FROM {$this->events_table}
-                 WHERE date = %s AND owner_id = %d AND content_type = %s AND event IN ('view_content', 'view_feed')
-                 GROUP BY content_id
-                 ORDER BY view_count DESC
-                 LIMIT 5",
-                $date,
-                $user_id,
-                $type
-            ));
-
-            if (!empty($items)) {
-                $top_content[$type] = array_map(function ($item) {
-                    return [
-                        'id' => $item->content_id,
-                        'views' => $item->view_count
-                    ];
-                }, $items);
-            }
-        }
-
-        return $top_content;
-    }
-
-    /**
-     * Get source breakdown for profile views
-     *
-     * @param string $date Date to query
-     * @param int $artist_id Artist profile ID
-     * @return array Source breakdown
-     */
-    protected function getSourceBreakdown(string $date, int $artist_id):array
-    {
-        $sources = [
-            'direct' => 0,
-            'feed' => 0,
-            'taxonomy' => 0,
-            'shop' => 0,
-            'search' => 0,
-            'other' => 0
-        ];
-
-        // Query for referrer distribution
-        $results = $this->wpdb->get_results($this->wpdb->prepare(
-            "SELECT referrer, COUNT(*) as count
-             FROM {$this->events_table}
-             WHERE date = %s AND event = 'view_profile' AND content_id = %d
-             GROUP BY referrer",
-            $date,
-            $artist_id
-        ));
-
-        foreach ($results as $row) {
-            $referrer = $row->referrer ?: 'direct';
-
-            switch ($referrer) {
-                case 'direct':
-                    $sources['direct'] = $row->count;
-                    break;
-                case 'feed':
-                    $sources['feed'] = $row->count;
-                    break;
-                case 'taxonomy':
-                    $sources['taxonomy'] = $row->count;
-                    break;
-                case 'shop':
-                    $sources['shop'] = $row->count;
-                    break;
-                case 'search':
-                    $sources['search'] = $row->count;
-                    break;
-                default:
-                    $sources['other'] += $row->count;
-            }
-        }
-
-        return $sources;
-    }
-
-    /**
-     * Get top favourited content for a user
-     *
-     * @param string $date Date to query
-     * @param int $user_id User ID
-     * @return array Top favourited content
-     */
-    protected function getTopFavourites(string $date, int $user_id):array
-    {
-        $results = $this->wpdb->get_results($this->wpdb->prepare(
-            "SELECT content_id, content_type, COUNT(*) as fav_count
-             FROM {$this->events_table}
-             WHERE date = %s AND owner_id = %d
-             AND event = 'toggle_favourite'
-             AND JSON_EXTRACT(metadata, '$.action') = 'add'
-             GROUP BY content_id, content_type
-             ORDER BY fav_count DESC
-             LIMIT 10",
-            $date,
-            $user_id
-        ));
-
-        $favourites = [];
-        foreach ($results as $row) {
-            if (!isset($favourites[$row->content_type])) {
-                $favourites[$row->content_type] = [];
-            }
-
-            $favourites[$row->content_type][] = [
-                'id' => $row->content_id,
-                'count' => $row->fav_count
-            ];
-        }
-
-        return $favourites;
-    }
-
-    /**
-     * Get metrics for a user over a period
-     *
-     * @param int $user_id User ID
-     * @param string $start_date Start date (YYYY-MM-DD)
-     * @param string $end_date End date (YYYY-MM-DD)
-     * @return array Metrics for the period
-     */
-    public function getUserMetrics(int $user_id, string $start_date, string $end_date):array
-    {
-        // Try to get from cache first
-        $cache_key = "user_metrics_{$user_id}_{$start_date}_{$end_date}";
-        $cached = $this->cache->get($cache_key);
-        if ($cached) {
-            return $cached;
-        }
-
-        // Query metrics for the time period
-        $metrics = $this->wpdb->get_results($this->wpdb->prepare(
-            "SELECT * FROM {$this->metrics_table}
-             WHERE user_id = %d AND date BETWEEN %s AND %s
-             ORDER BY date",
-            $user_id,
-            $start_date,
-            $end_date
-        ));
-
-        // Prepare aggregated data
-        $aggregated = [
-            'period' => [
-                'start' => $start_date,
-                'end' => $end_date
-            ],
-            'totals' => [
-                'profile_views' => 0,
-                'feed_views' => 0,
-                'taxonomy_views' => 0,
-                'shop_views' => 0,
-                'total_views' => 0,
-                'favourites' => 0,
-                'upvotes' => 0,
-                'downvotes' => 0,
-                'karma' => 0
-            ],
-            'conversion_rates' => [
-                'favourite_rate' => 0
-            ],
-            'daily' => [],
-            'top_content' => $this->aggregateTopContent($metrics),
-            'source_breakdown' => $this->aggregateSourceBreakdown($metrics),
-            'top_favourites' => $this->aggregateTopFavourites($metrics)
-        ];
-
-        // Process each day
-        foreach ($metrics as $day) {
-            // Add to totals
-            $aggregated['totals']['profile_views'] += $day->profile_view_count;
-            $aggregated['totals']['feed_views'] += $day->feed_view_count;
-            $aggregated['totals']['taxonomy_views'] += $day->taxonomy_view_count;
-            $aggregated['totals']['shop_views'] += $day->shop_view_count;
-            $aggregated['totals']['total_views'] += $day->total_view_count;
-            $aggregated['totals']['favourites'] += $day->favourite_count;
-            $aggregated['totals']['upvotes'] += $day->upvote_count;
-            $aggregated['totals']['downvotes'] += $day->downvote_count;
-            $aggregated['totals']['karma'] += $day->karma;
-
-            // Add daily data
-            $aggregated['daily'][$day->date] = [
-                'profile_views' => $day->profile_view_count,
-                'feed_views' => $day->feed_view_count,
-                'taxonomy_views' => $day->taxonomy_view_count,
-                'shop_views' => $day->shop_view_count,
-                'total_views' => $day->total_view_count,
-                'favourites' => $day->favourite_count,
-                'upvotes' => $day->upvote_count,
-                'downvotes' => $day->downvote_count,
-                'karma' => $day->karma
-            ];
-        }
-
-        // Calculate conversion rates for the period
-        if ($aggregated['totals']['total_views'] > 0) {
-            $aggregated['conversion_rates']['favourite_rate'] = round(
-                $aggregated['totals']['favourites'] / $aggregated['totals']['total_views'],
-                4
-            );
-        }
-
-        // Add growth metrics
-        $aggregated['growth'] = $this->calculateGrowthMetrics($user_id, $start_date, $end_date);
-
-        // Cache the results
-        $this->cache->set($cache_key, $aggregated);
-
-        return $aggregated;
-    }
-
-    /**
-     * Calculate growth metrics comparing to previous period
-     *
-     * @param int $user_id User ID
-     * @param string $start_date Start date
-     * @param string $end_date End date
-     * @return array Growth metrics
-     */
-    protected function calculateGrowthMetrics(int $user_id, string $start_date, string $end_date)
-    {
-        // Calculate the date range length
-        $current_range_days = (strtotime($end_date) - strtotime($start_date)) / DAY_IN_SECONDS + 1;
-
-        // Calculate previous period with same length
-        $prev_end_date = date('Y-m-d', strtotime($start_date . ' -1 day'));
-        $prev_start_date = date('Y-m-d', strtotime($prev_end_date . " -{$current_range_days} days +1 day"));
-
-        // Get metrics for previous period
-        $prev_metrics = $this->wpdb->get_results($this->wpdb->prepare(
-            "SELECT
-                SUM(profile_view_count) as profile_views,
-                SUM(feed_view_count) as feed_views,
-                SUM(taxonomy_view_count) as taxonomy_views,
-                SUM(shop_view_count) as shop_views,
-                SUM(total_view_count) as total_views,
-                SUM(favourite_count) as favourites,
-                SUM(upvote_count) as upvotes,
-                SUM(downvote_count) as downvotes,
-                SUM(karma) as karma
-             FROM {$this->metrics_table}
-             WHERE user_id = %d AND date BETWEEN %s AND %s",
-            $user_id,
-            $prev_start_date,
-            $prev_end_date
-        ));
-
-        // Get current period totals
-        $current_metrics = $this->wpdb->get_results($this->wpdb->prepare(
-            "SELECT
-                SUM(profile_view_count) as profile_views,
-                SUM(feed_view_count) as feed_views,
-                SUM(taxonomy_view_count) as taxonomy_views,
-                SUM(shop_view_count) as shop_views,
-                SUM(total_view_count) as total_views,
-                SUM(favourite_count) as favourites,
-                SUM(upvote_count) as upvotes,
-                SUM(downvote_count) as downvotes,
-                SUM(karma) as karma
-             FROM {$this->metrics_table}
-             WHERE user_id = %d AND date BETWEEN %s AND %s",
-            $user_id,
-            $start_date,
-            $end_date
-        ));
-
-        $prev = !empty($prev_metrics) ? $prev_metrics[0] : null;
-        $curr = !empty($current_metrics) ? $current_metrics[0] : null;
-
-        // If no previous data, return zeros
-        if (!$prev || !$curr) {
-            return [
-                'profile_views' => 0,
-                'feed_views' => 0,
-                'taxonomy_views' => 0,
-                'shop_views' => 0,
-                'total_views' => 0,
-                'favourites' => 0,
-                'upvotes' => 0,
-                'downvotes' => 0,
-                'karma' => 0
-            ];
-        }
-
-        // Calculate percentage changes
-        $growth = [];
-        $metrics = [
-            'profile_views', 'feed_views', 'taxonomy_views', 'shop_views',
-            'total_views', 'favourites', 'upvotes', 'downvotes', 'karma'
-        ];
-
-        foreach ($metrics as $metric) {
-            if ($prev->$metric > 0) {
-                $growth[$metric] = round((($curr->$metric - $prev->$metric) / $prev->$metric) * 100, 1);
-            } else {
-                $growth[$metric] = $curr->$metric > 0 ? 100 : 0; // If previous was 0, any value is 100% growth
-            }
-        }
-
-        return $growth;
-    }
-
-    /**
-     * Aggregate top content from multiple days
-     *
-     * @param array $metrics Array of metrics objects
-     * @return array Aggregated top content
-     */
-    protected function aggregateTopContent(array $metrics):array
-    {
-        $all_content = [];
-
-        // Collect content from all days
-        foreach ($metrics as $day) {
-            $daily_content = json_decode($day->top_content, true);
-            if (!empty($daily_content)) {
-                foreach ($daily_content as $type => $content_items) {
-                    if (!isset($all_content[$type])) {
-                        $all_content[$type] = [];
-                    }
-
-                    foreach ($content_items as $item) {
-                        $id = $item['id'];
-                        if (!isset($all_content[$type][$id])) {
-                            $all_content[$type][$id] = [
-                                'id' => $id,
-                                'views' => 0,
-                                'title' => $this->getContentTitle($id, $type)
-                            ];
-                        }
-
-                        $all_content[$type][$id]['views'] += $item['views'];
-                    }
-                }
-            }
-        }
-
-        // Sort and trim each content type
-        $result = [];
-        foreach ($all_content as $type => $items) {
-            // Convert to array and sort by views
-            $items_array = array_values($items);
-            usort($items_array, function ($a, $b) {
-                return $b['views'] - $a['views'];
-            });
-
-            // Take top 10
-            $result[$type] = array_slice($items_array, 0, 10);
-        }
-
-        return $result;
-    }
-
-    /**
-     * Aggregate source breakdown from multiple days
-     *
-     * @param array $metrics Array of metrics objects
-     * @return array Aggregated source breakdown
-     */
-    protected function aggregateSourceBreakdown(array $metrics):array
-    {
-        $totals = [
-            'direct' => 0,
-            'feed' => 0,
-            'taxonomy' => 0,
-            'shop' => 0,
-            'search' => 0,
-            'other' => 0
-        ];
-
-        foreach ($metrics as $day) {
-            $sources = json_decode($day->source_breakdown, true);
-            if (!empty($sources)) {
-                foreach ($sources as $source => $count) {
-                    $totals[$source] += $count;
-                }
-            }
-        }
-
-        return $totals;
-    }
-
-    /**
-     * Aggregate top favourites from multiple days
-     *
-     * @param array $metrics Array of metrics objects
-     * @return array Aggregated top favourites
-     */
-    protected function aggregateTopFavourites(array $metrics):array
-    {
-        $all_favourites = [];
-
-        // Collect favourites from all days
-        foreach ($metrics as $day) {
-            $daily_favs = json_decode($day->top_favourites, true);
-            if (!empty($daily_favs)) {
-                foreach ($daily_favs as $type => $fav_items) {
-                    if (!isset($all_favourites[$type])) {
-                        $all_favourites[$type] = [];
-                    }
-
-                    foreach ($fav_items as $item) {
-                        $id = $item['id'];
-                        if (!isset($all_favourites[$type][$id])) {
-                            $all_favourites[$type][$id] = [
-                                'id' => $id,
-                                'count' => 0,
-                                'title' => $this->getContentTitle($id, $type)
-                            ];
-                        }
-
-                        $all_favourites[$type][$id]['count'] += $item['count'];
-                    }
-                }
-            }
-        }
-
-        // Sort and trim each content type
-        $result = [];
-        foreach ($all_favourites as $type => $items) {
-            // Convert to array and sort by count
-            $items_array = array_values($items);
-            usort($items_array, function ($a, $b) {
-                return $b['count'] - $a['count'];
-            });
-
-            // Take top 10
-            $result[$type] = array_slice($items_array, 0, 10);
-        }
-
-        return $result;
-    }
-
-    /**
-     * Get content title by ID and type
-     *
-     * @param int $id Content ID
-     * @param string $type Content type
-     * @return string Content title or ID if not found
-     */
-    protected function getContentTitle(int $id, string $type):string
-    {
-        // Try to get from cache first
-        $cache_key = "content_title_{$type}_{$id}";
-        $cached = $this->cache->get($cache_key);
-        if ($cached) {
-            return $cached;
-        }
-
-        $title = '';
-
-        // Get title based on content type
-        if (strpos($type, 'post_') === 0 ||
-            in_array($type, ['tattoo', 'artist', 'piercing', 'artwork', 'event', 'offer'])) {
-            // It's a post
-            $post_id = $id;
-            $post = get_post($post_id);
-            if ($post) {
-                $title = $post->post_title;
-            }
-        } elseif (in_array($type, ['shop', 'style', 'theme', 'city'])) {
-            // It's a taxonomy term
-            $term = get_term($id);
-            if (!is_wp_error($term)) {
-                $title = $term->name;
-            }
-        }
-
-        // If still empty, use ID as fallback
-        if (empty($title)) {
-            $title = "#$id";
-        }
-
-        // Cache the result
-        $this->cache->set($cache_key, $title, MONTH_IN_SECONDS);
-
-        return $title;
-    }
-
-    /**
-     * Get metrics for a shop over a period
-     *
-     * @param int $shop_id Shop ID (term_id)
-     * @param string $start_date Start date (YYYY-MM-DD)
-     * @param string $end_date End date (YYYY-MM-DD)
-     * @return array Metrics for the period
-     */
-    public function getShopMetrics(int $shop_id, string $start_date, string $end_date):array
-    {
-        // Try to get from cache first
-        $cache_key = "shop_metrics_{$shop_id}_{$start_date}_{$end_date}";
-        $cached = $this->cache->get($cache_key);
-        if ($cached) {
-            return $cached;
-        }
-
-        // Query raw events for this shop
-        $shop_views = $this->wpdb->get_var($this->wpdb->prepare(
-            "SELECT COUNT(*) FROM {$this->events_table}
-             WHERE event = 'view_shop'
-             AND content_id = %d
-             AND date BETWEEN %s AND %s",
-            $shop_id,
-            $start_date,
-            $end_date
-        ));
-
-        // Get all artists for this shop
-        $shop_artists = get_objects_in_term($shop_id, BASE . 'shop');
-        $artist_user_ids = [];
-
-        foreach ($shop_artists as $artist_post_id) {
-            $user_id = get_post_meta($artist_post_id, BASE . 'link', true);
-            if ($user_id) {
-                $artist_user_ids[] = $user_id;
-            }
-        }
-
-        // If no artists, return basic data
-        if (empty($artist_user_ids)) {
-            $shop_data = [
-                'shop_id' => $shop_id,
-                'period' => [
-                    'start' => $start_date,
-                    'end' => $end_date
-                ],
-                'views' => $shop_views,
-                'artists' => 0,
-                'artist_metrics' => []
-            ];
-
-            $this->cache->set($cache_key, $shop_data);
-            return $shop_data;
-        }
-
-        // Format artist IDs for SQL
-        $artist_ids_sql = implode(',', array_map('intval', $artist_user_ids));
-
-        // Get aggregated artist metrics
-        $artist_metrics = $this->wpdb->get_results(
-            "SELECT
-                user_id,
-                SUM(profile_view_count) as profile_views,
-                SUM(feed_view_count) as feed_views,
-                SUM(taxonomy_view_count) as taxonomy_views,
-                SUM(shop_view_count) as shop_views,
-                SUM(total_view_count) as total_views,
-                SUM(favourite_count) as favourites,
-                SUM(upvote_count) as upvotes,
-                SUM(downvote_count) as downvotes,
-                SUM(karma) as karma
-             FROM {$this->metrics_table}
-             WHERE user_id IN ({$artist_ids_sql}) AND date BETWEEN '{$start_date}' AND '{$end_date}'
-             GROUP BY user_id
-             ORDER BY total_views DESC"
-        );
-
-        // Process artist metrics
-        $formatted_artists = [];
-        $totals = [
-            'profile_views' => 0,
-            'feed_views' => 0,
-            'taxonomy_views' => 0,
-            'shop_views' => 0,
-            'total_views' => 0,
-            'favourites' => 0,
-            'upvotes' => 0,
-            'downvotes' => 0,
-            'karma' => 0
-        ];
-
-        foreach ($artist_metrics as $artist) {
-            // Get artist name
-            $artist_name = '';
-            $artist_post_id = get_user_meta($artist->user_id, BASE . 'link', true);
-            if ($artist_post_id) {
-                $artist_name = get_post_field('post_title', $artist_post_id);
-            }
-
-            $formatted_artists[] = [
-                'user_id' => $artist->user_id,
-                'name' => $artist_name ?: 'Artist #' . $artist->user_id,
-                'metrics' => [
-                    'profile_views' => (int)$artist->profile_views,
-                    'feed_views' => (int)$artist->feed_views,
-                    'taxonomy_views' => (int)$artist->taxonomy_views,
-                    'shop_views' => (int)$artist->shop_views,
-                    'total_views' => (int)$artist->total_views,
-                    'favourites' => (int)$artist->favourites,
-                    'upvotes' => (int)$artist->upvotes,
-                    'downvotes' => (int)$artist->downvotes,
-                    'karma' => (int)$artist->karma
-                ]
-            ];
-
-            // Add to totals
-            $totals['profile_views'] += $artist->profile_views;
-            $totals['feed_views'] += $artist->feed_views;
-            $totals['taxonomy_views'] += $artist->taxonomy_views;
-            $totals['shop_views'] += $artist->shop_views;
-            $totals['total_views'] += $artist->total_views;
-            $totals['favourites'] += $artist->favourites;
-            $totals['upvotes'] += $artist->upvotes;
-            $totals['downvotes'] += $artist->downvotes;
-            $totals['karma'] += $artist->karma;
-        }
-
-        // Get daily view counts
-        $daily_views = $this->wpdb->get_results($this->wpdb->prepare(
-            "SELECT date, COUNT(*) as views
-             FROM {$this->events_table}
-             WHERE event = 'view_shop'
-             AND content_id = %d
-             AND date BETWEEN %s AND %s
-             GROUP BY date
-             ORDER BY date",
-            $shop_id,
-            $start_date,
-            $end_date
-        ));
-
-        $views_by_day = [];
-        foreach ($daily_views as $day) {
-            $views_by_day[$day->date] = $day->views;
-        }
-
-        // Build final shop data
-        $shop_data = [
-            'shop_id' => $shop_id,
-            'shop_name' => get_term_field('name', $shop_id, BASE . 'shop'),
-            'period' => [
-                'start' => $start_date,
-                'end' => $end_date
-            ],
-            'views' => $shop_views,
-            'views_by_day' => $views_by_day,
-            'artists' => count($formatted_artists),
-            'artist_metrics' => $formatted_artists,
-            'totals' => $totals
-        ];
-
-        // Calculate growth compared to previous period
-        $shop_data['growth'] = $this->calculateShopGrowth($shop_id, $shop_artists, $start_date, $end_date);
-
-        // Cache the result
-        $this->cache->set($cache_key, $shop_data);
-
-        return $shop_data;
-    }
-
-    /**
-     * Calculate shop growth metrics
-     *
-     * @param int $shop_id Shop ID
-     * @param array $shop_artists Array of artist post IDs
-     * @param string $start_date Start date
-     * @param string $end_date End date
-     * @return array Growth metrics
-     */
-    protected function calculateShopGrowth(int $shop_id, array $shop_artists, string $start_date, string $end_date):array
-    {
-        // Calculate the date range length
-        $current_range_days = (strtotime($end_date) - strtotime($start_date)) / DAY_IN_SECONDS + 1;
-
-        // Calculate previous period with same length
-        $prev_end_date = date('Y-m-d', strtotime($start_date . ' -1 day'));
-        $prev_start_date = date('Y-m-d', strtotime($prev_end_date . " -{$current_range_days} days +1 day"));
-
-        // Get previous shop views
-        $prev_shop_views = $this->wpdb->get_var($this->wpdb->prepare(
-            "SELECT COUNT(*) FROM {$this->events_table}
-             WHERE event = 'view_shop'
-             AND content_id = %d
-             AND date BETWEEN %s AND %s",
-            $shop_id,
-            $prev_start_date,
-            $prev_end_date
-        ));
-
-        // Get current shop views
-        $current_shop_views = $this->wpdb->get_var($this->wpdb->prepare(
-            "SELECT COUNT(*) FROM {$this->events_table}
-             WHERE event = 'view_shop'
-             AND content_id = %d
-             AND date BETWEEN %s AND %s",
-            $shop_id,
-            $start_date,
-            $end_date
-        ));
-
-        // Calculate growth percentage for shop views
-        $shop_view_growth = 0;
-        if ($prev_shop_views > 0) {
-            $shop_view_growth = round((($current_shop_views - $prev_shop_views) / $prev_shop_views) * 100, 1);
-        } elseif ($current_shop_views > 0) {
-            $shop_view_growth = 100; // If previously 0, any value is 100% growth
-        }
-
-        // Get artist user IDs
-        $artist_user_ids = [];
-        foreach ($shop_artists as $artist_post_id) {
-            $user_id = get_post_meta($artist_post_id, BASE . 'link', true);
-            if ($user_id) {
-                $artist_user_ids[] = $user_id;
-            }
-        }
-
-        // If no artists, just return shop views growth
-        if (empty($artist_user_ids)) {
-            return [
-                'shop_views' => $shop_view_growth
-            ];
-        }
-
-        // Format artist IDs for SQL
-        $artist_ids_sql = implode(',', array_map('intval', $artist_user_ids));
-
-        // Get previous period metrics for artists
-        $prev_metrics = $this->wpdb->get_row(
-            "SELECT
-                SUM(profile_view_count) as profile_views,
-                SUM(feed_view_count) as feed_views,
-                SUM(taxonomy_view_count) as taxonomy_views,
-                SUM(shop_view_count) as shop_views,
-                SUM(total_view_count) as total_views,
-                SUM(favourite_count) as favourites,
-                SUM(upvote_count) as upvotes,
-                SUM(downvote_count) as downvotes,
-                SUM(karma) as karma
-             FROM {$this->metrics_table}
-             WHERE user_id IN ({$artist_ids_sql}) AND date BETWEEN '{$prev_start_date}' AND '{$prev_end_date}'"
-        );
-
-        // Get current period metrics for artists
-        $current_metrics = $this->wpdb->get_row(
-            "SELECT
-                SUM(profile_view_count) as profile_views,
-                SUM(feed_view_count) as feed_views,
-                SUM(taxonomy_view_count) as taxonomy_views,
-                SUM(shop_view_count) as shop_views,
-                SUM(total_view_count) as total_views,
-                SUM(favourite_count) as favourites,
-                SUM(upvote_count) as upvotes,
-                SUM(downvote_count) as downvotes,
-                SUM(karma) as karma
-             FROM {$this->metrics_table}
-             WHERE user_id IN ({$artist_ids_sql}) AND date BETWEEN '{$start_date}' AND '{$end_date}'"
-        );
-
-        // Calculate growth percentages
-        $growth = [
-            'shop_views' => $shop_view_growth
-        ];
-
-        if ($prev_metrics && $current_metrics) {
-            $metrics = [
-                'profile_views', 'feed_views', 'taxonomy_views', 'shop_views',
-                'total_views', 'favourites', 'upvotes', 'downvotes', 'karma'
-            ];
-
-            foreach ($metrics as $metric) {
-                if ($prev_metrics->$metric > 0) {
-                    $growth[$metric] = round((($current_metrics->$metric - $prev_metrics->$metric) / $prev_metrics->$metric) * 100, 1);
-                } else {
-                    $growth[$metric] = $current_metrics->$metric > 0 ? 100 : 0;
-                }
-            }
-        }
-
-        return $growth;
-    }
-
-    /**
-     * Get dashboard summary for a user
-     *
-     * @param int $user_id User ID
-     * @return array Dashboard summary data
-     */
-    public function getDashboardSummary(int $user_id):array
-    {
-        // Get metrics for different time periods
-        $today = date('Y-m-d');
-        $yesterday = date('Y-m-d', strtotime('-1 day'));
-        $week_start = date('Y-m-d', strtotime('monday this week'));
-        $month_start = date('Y-m-d', strtotime('first day of this month'));
-
-        // Quick summary for today and yesterday
-        $today_data = $this->getUserMetrics($user_id, $today, $today);
-        $yesterday_data = $this->getUserMetrics($user_id, $yesterday, $yesterday);
-        $week_data = $this->getUserMetrics($user_id, $week_start, $today);
-        $month_data = $this->getUserMetrics($user_id, $month_start, $today);
-
-        // Build quick summary for dashboard
-		return [
-			'today' => [
-				'total_views' => $today_data['totals']['total_views'],
-				'profile_views' => $today_data['totals']['profile_views'],
-				'favourites' => $today_data['totals']['favourites'],
-				'karma' => $today_data['totals']['karma']
-			],
-			'yesterday' => [
-				'total_views' => $yesterday_data['totals']['total_views'],
-				'profile_views' => $yesterday_data['totals']['profile_views'],
-				'favourites' => $yesterday_data['totals']['favourites'],
-				'karma' => $yesterday_data['totals']['karma']
-			],
-			'this_week' => [
-				'total_views' => $week_data['totals']['total_views'],
-				'profile_views' => $week_data['totals']['profile_views'],
-				'favourites' => $week_data['totals']['favourites'],
-				'karma' => $week_data['totals']['karma']
-			],
-			'this_month' => [
-				'total_views' => $month_data['totals']['total_views'],
-				'profile_views' => $month_data['totals']['profile_views'],
-				'favourites' => $month_data['totals']['favourites'],
-				'karma' => $month_data['totals']['karma']
-			],
-			'growth' => [
-				'day_over_day' => [
-					'total_views' => $this->calculatePercentageChange(
-						$yesterday_data['totals']['total_views'],
-						$today_data['totals']['total_views']
-					),
-					'profile_views' => $this->calculatePercentageChange(
-						$yesterday_data['totals']['profile_views'],
-						$today_data['totals']['profile_views']
-					),
-					'favourites' => $this->calculatePercentageChange(
-						$yesterday_data['totals']['favourites'],
-						$today_data['totals']['favourites']
-					),
-					'karma' => $this->calculateAbsoluteChange(
-						$yesterday_data['totals']['karma'],
-						$today_data['totals']['karma']
-					)
-				]
-			],
-			'top_content' => $this->simplifyTopContent($week_data['top_content']),
-			'sources' => $week_data['source_breakdown']
-		];
-    }
-
-    /**
-     * Calculate percentage change between two values
-     *
-     * @param int $old Previous value
-     * @param int $new Current value
-     * @return float Percentage change
-     */
-    protected function calculatePercentageChange(int $old, int $new):float
-    {
-        if ($old == 0) {
-            return $new > 0 ? 100 : 0;
-        }
-
-        return round((($new - $old) / $old) * 100, 1);
-    }
-
-    /**
-     * Calculate absolute change between two values
-     *
-     * @param int $old Previous value
-     * @param int $new Current value
-     * @return int Absolute change
-     */
-    protected function calculateAbsoluteChange(int $old, int $new):int
-    {
-        return $new - $old;
-    }
-
-    /**
-     * Simplify top content for dashboard view
-     *
-     * @param array $top_content Full top content data
-     * @return array Simplified top content
-     */
-    protected function simplifyTopContent(array $top_content):array
-    {
-        $simplified = [];
-
-        foreach ($top_content as $type => $items) {
-            // Take just top 3 items
-            $simplified[$type] = array_slice($items, 0, 3);
-        }
-
-        return $simplified;
-    }
-
-    /**
-     * Update table schema with needed columns
-     */
-    public static function createTableSchema()
-    {
-        global $wpdb;
-
-        $charset_collate = $wpdb->get_charset_collate();
-
-        // Events table
-        $events_table = $wpdb->prefix . BASE . 'umami_events';
-        $events_schema = "CREATE TABLE IF NOT EXISTS {$events_table} (
-            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
-            `date` date NOT NULL,
-            `timestamp` datetime NOT NULL,
-            `event` varchar(50) NOT NULL,
-            `event_type` varchar(50) NOT NULL,
-            `user_id` bigint(20) unsigned DEFAULT NULL,
-            `content_id` bigint(20) unsigned DEFAULT NULL,
-            `content_type` varchar(50) DEFAULT NULL,
-            `source_id` bigint(20) unsigned DEFAULT NULL,
-            `source_type` varchar(50) DEFAULT NULL,
-            `owner_id` bigint(20) unsigned DEFAULT NULL,
-            `owner_type` varchar(50) DEFAULT NULL,
-            `referrer` varchar(100) DEFAULT NULL,
-            `metadata` JSON DEFAULT NULL,
-            PRIMARY KEY (`id`),
-            KEY `date_idx` (`date`),
-            KEY `event_idx` (`event`, `event_type`),
-            KEY `content_idx` (`content_type`, `content_id`),
-            KEY `user_idx` (`user_id`),
-            KEY `owner_idx` (`owner_id`)
-        ) {$charset_collate};";
-
-        // Performance metrics table
-        $metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
-        $metrics_schema = "CREATE TABLE IF NOT EXISTS {$metrics_table} (
-            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
-            `date` date NOT NULL,
-            `user_id` bigint(20) unsigned DEFAULT NULL,
-            `profile_view_count` bigint(20) unsigned DEFAULT 0,
-            `feed_view_count` bigint(20) unsigned DEFAULT 0,
-            `taxonomy_view_count` bigint(20) unsigned DEFAULT 0,
-            `shop_view_count` bigint(20) unsigned DEFAULT 0,
-            `total_view_count` bigint(20) unsigned DEFAULT 0,
-            `favourite_count` bigint(20) unsigned DEFAULT 0,
-            `top_content` json DEFAULT NULL,
-            `source_breakdown` json DEFAULT NULL,
-            `top_favourites` json DEFAULT NULL,
-            `upvote_count` bigint(20) unsigned DEFAULT 0,
-            `downvote_count` bigint(20) unsigned DEFAULT 0,
-            `karma` bigint(20) unsigned DEFAULT 0,
-            `favourite_conversion_rate` float DEFAULT 0,
-            PRIMARY KEY (`id`),
-            UNIQUE KEY `user_date_idx` (`user_id`, `date`),
-            KEY `date_idx` (`date`)
-        ) {$charset_collate};";
-
-        // Execute schema creation
-        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
-        dbDelta($events_schema);
-        dbDelta($metrics_schema);
-    }
-
-    /**
-     * Handle database upgrades for schema changes
-     */
-    public static function upgradeDatabase()
-    {
-        self::createTableSchema();
-    }
-
-    /**
-     * Render the Umami credentials settings form
-     */
-    public function renderCredentialsForm()
-    {
-        // Check capability
-        if (!current_user_can('manage_options')) {
-            wp_die(__('You do not have sufficient permissions to access this page.'));
-        }
-
-        // Handle form submission
-        if (isset($_POST['jvb_umami_credentials_nonce']) && wp_verify_nonce($_POST['jvb_umami_credentials_nonce'], 'jvb_saveUmamiCredentials')) {
-            if (isset($_POST['jvb_umami_username']) && isset($_POST['jvb_umami_password'])) {
-                $username = sanitize_text_field($_POST['jvb_umami_username']);
-                $password = $_POST['jvb_umami_password']; // Don't sanitize password as it might contain special chars
-
-                if ($this->saveUmamiCredentials($username, $password)) {
-                    // Clear token cache to force re-authentication
-                    delete_transient('jvb_umami_auth_token');
-                    echo '<div class="updated"><p>Credentials saved successfully!</p></div>';
-
-                    // Test connection
-                    $test_result = $this->testUmamiConnection();
-                    if (is_wp_error($test_result)) {
-                        echo '<div class="error"><p>Connection test failed: ' . esc_html($test_result->get_error_message()) . '</p></div>';
-                    } else {
-                        echo '<div class="updated"><p>Connection test successful!</p></div>';
-                    }
-                } else {
-                    echo '<div class="error"><p>Failed to save credentials.</p></div>';
-                }
-            }
-        }
-
-        // Render form
-        ?>
-        <div class="wrap">
-            <h2>Umami Analytics Credentials</h2>
-            <form method="post" action="">
-                <?php wp_nonce_field('jvb_saveUmamiCredentials', 'jvb_umami_credentials_nonce'); ?>
-                <table class="form-table">
-                    <tr>
-                        <th scope="row"><label for="jvb_umami_username">Username</label></th>
-                        <td><input type="text" id="jvb_umami_username" name="jvb_umami_username" class="regular-text"></td>
-                    </tr>
-                    <tr>
-                        <th scope="row"><label for="jvb_umami_password">Password</label></th>
-                        <td><input type="password" id="jvb_umami_password" name="jvb_umami_password" class="regular-text"></td>
-                    </tr>
-                    <tr>
-                        <th scope="row"><label for="jvb_umami_website_id">Website ID</label></th>
-                        <td>
-                            <input type="text" id="jvb_umami_website_id" name="jvb_umami_website_id" class="regular-text" value="<?= esc_attr(get_option('jvb_umami_website_id', UMAMI_WEBSITE_ID)); ?>">
-                            <p class="description">The Umami website ID for tracking.</p>
-                        </td>
-                    </tr>
-                </table>
-                <p class="submit">
-                    <input type="submit" name="submit" id="submit" class="button button-primary" value="Save Credentials">
-                </p>
-            </form>
-        </div>
-        <?php
-    }
-
-    /**
-     * Test connection to Umami API
-     *
-     * @return bool|WP_Error True on success, WP_Error on failure
-     */
-    public function testUmamiConnection()
-    {
-        $token = $this->getAuthToken();
-        if (is_wp_error($token)) {
-            return $token;
-        }
-
-        // Try a simple API call
-        $test_response = wp_remote_get($this->api_url . '/me', [
-            'headers' => [
-                'Authorization' => 'Bearer ' . $token
-            ]
-        ]);
-
-        if (is_wp_error($test_response)) {
-            return $test_response;
-        }
-
-        $response_code = wp_remote_retrieve_response_code($test_response);
-        if ($response_code !== 200) {
-            return new WP_Error(
-                'connection_failed',
-                'API responded with code: ' . $response_code
-            );
-        }
-
-        return true;
-    }
-}
diff --git a/inc/managers/UmamiTracker.php b/inc/managers/UmamiTracker.php
deleted file mode 100644
index 76e5bf6..0000000
--- a/inc/managers/UmamiTracker.php
+++ /dev/null
@@ -1,249 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-if (!defined('ABSPATH')) {
-    exit; // Exit if accessed directly
-}
-
-/**
- * Class for building and managing umami.js tracking data attributes
- */
-class UmamiTracker
-{
-    private array $valid_events = [
-        'view_feed',
-        'view_taxonomy',
-        'view_profile',
-        'view_shop',
-        'view_content',
-        'toggle_favourite',
-        'click_profile',
-        'click_content',
-        'click_taxonomy',
-        'click_shop',
-    ];
-    private array $valid_types;
-    private array $context;
-
-    /**
-     * Constructor
-     *
-     * @param array $context Optional context data to use for all tracking
-     */
-    public function __construct(array $context = [])
-    {
-        $this->context = $context;
-        global $jvb_content;
-        $this->valid_types = array_keys($jvb_content);
-
-
-        // Add current page information if available
-        if (empty($this->context['source_id']) && is_singular()) {
-            $this->context['source_id']   = get_the_ID();
-            $this->context['source_type'] = get_post_type();
-        } elseif (empty($this->context['source_id']) && is_tax()) {
-            $obj                          = get_queried_object();
-            $this->context['source_id']   = $obj->term_id;
-            $this->context['source_type'] = $obj->taxonomy;
-        }
-    }
-
-    /**
-     * Build tracking attributes for a specific event
-     *
-     * @param string $event Event type
-     * @param string $type Content type
-     * @param array $args Additional arguments
-     *
-     * @return array/string HTML attributes for tracking
-     */
-    public function buildAttributes(string $event, string $type, array $args = []):array
-    {
-        // Validate event and type
-        if (!in_array($event, $this->valid_events)) {
-            return [];
-        }
-
-        if (!in_array($type, $this->valid_types)) {
-            return [];
-        }
-
-        // Merge with context
-        $args = array_merge($this->context, $args);
-
-        // Normalize type (remove jvb_ prefix)
-        $normalized_type = str_replace(BASE, '', $type);
-
-        // Start building attributes
-        $attributes = [
-            'umamiEvent'      => esc_attr($event),
-            'umamiEventType' => esc_attr($normalized_type)
-        ];
-
-        // Add content ID if available
-        if (!empty($args['id'])) {
-            $attributes['umamiEventId'] = esc_attr($args['id']);
-        }
-
-        // Add source info (where the action originated)
-        if (!empty($args['source_id'])) {
-            $attributes['umamiEventSource'] = esc_attr($args['source_id']);
-
-            if (!empty($args['source_type'])) {
-                $attributes['umamiEventSourceType'] = esc_attr(
-                    str_replace(BASE, '', $args['source_type'])
-                );
-            }
-        }
-
-        // Add owner info (usually artist or partner)
-        if (!empty($args['owner_id'])) {
-            $attributes['umamiEventOwner'] = esc_attr($args['owner_id']);
-
-            if (!empty($args['owner_type'])) {
-                $attributes['umamiEventOwnerType'] = esc_attr(
-                    str_replace(BASE, '', $args['owner_type'])
-                );
-            }
-        }
-
-        // Add referrer (how the user got here)
-        if (!empty($args['from'])) {
-            $attributes['umamiEventFrom'] = esc_attr($args['from']);
-        }
-
-        // Add item (used in views to see what people are clicking on)
-        if (!empty($args['item'])) {
-            $attributes['umamiEventItem'] = esc_attr($args['item']);
-        }
-
-        // Add any additional metadata
-        if (!empty($args['meta']) && is_array($args['meta'])) {
-            foreach ($args['meta'] as $key => $value) {
-                $attributes['umamiEventMeta' . ucFirst(str_replace('-', '', sanitize_key($key)))] = esc_attr($value);
-            }
-        }
-
-        return $attributes;
-    }
-
-    /**
-     * Build tracking attributes for content clicks
-     *
-     * @param int $id Content ID
-     * @param string $type Content type
-     * @param array $args Additional arguments
-     *
-     * @return array HTML attributes for tracking
-     */
-    public function trackContentClick(int $id, string $type, array $args = []):array
-    {
-        $args['id'] = $id;
-
-        // Auto-detect owner for content types
-        if (empty($args['owner_id']) && in_array($type, [ 'jvb_tattoo', 'jvb_artwork', 'jvb_piercing' ])) {
-            $post = get_post($id);
-            if ($post && ! empty($post->post_author)) {
-                $args['owner_id']   = $post->post_author;
-                $args['owner_type'] = 'user';
-            }
-        }
-
-        return $this->buildAttributes('click_content', $type, $args);
-    }
-
-    /**
-     * Build tracking attributes for profile clicks
-     *
-     * @param int $id Profile ID
-     * @param string $type Profile type (usually jvb_artist or jvb_partner)
-     * @param array $args Additional arguments
-     *
-     * @return array HTML attributes for tracking
-     */
-    public function trackProfileClick(int $id, string $type, array $args = []):array
-    {
-        $args['id'] = $id;
-        return $this->buildAttributes('click_profile', $type, $args);
-    }
-
-    /**
-     * Build tracking attributes for taxonomy clicks
-     *
-     * @param int $id Term ID
-     * @param string $taxonomy Taxonomy
-     * @param array $args Additional arguments
-     *
-     * @return array HTML attributes for tracking
-     */
-    public function trackTaxonomyClick(int $id, string $taxonomy, array $args = [])
-    {
-        $args['id'] = $id;
-        return $this->buildAttributes('click_taxonomy', $taxonomy, $args);
-    }
-
-    /**
-     * Build tracking attributes for shop clicks
-     *
-     * @param int $id Shop ID
-     * @param array $args Additional arguments
-     *
-     * @return array HTML attributes for tracking
-     */
-    public function trackShopClick(int $id, string $type = 'shop', array $args = []):array
-    {
-        $args['id'] = $id;
-        return $this->buildAttributes('click_shop', $type, $args);
-    }
-
-    /**
-     * Build tracking attributes for favourite toggles
-     *
-     * @param int $id Content ID
-     * @param string $type Content type
-     * @param bool $is_favourite Whether it's being favourited or unfavourited
-     * @param array $args Additional arguments
-     *
-     * @return array HTML attributes for tracking
-     */
-    public function trackFavouriteToggle(int $id, string $type, bool $is_favourite, array $args = [])
-    {
-        $args['id']             = $id;
-        $args['meta']['action'] = $is_favourite ? 'add' : 'remove';
-        return $this->buildAttributes('toggle_favourite', $type, $args);
-    }
-
-    /**
-     * Track content view in feed
-     *
-     * @param int $id Content ID
-     * @param string $type Content type
-     * @param array $args Additional arguments
-     *
-     * @return array HTML attributes for tracking
-     */
-    public function trackFeedView(int $id, string $type, array $args = []):array
-    {
-        $args['id'] = $id;
-        return $this->buildAttributes('view_feed', $type, $args);
-    }
-
-    /**
-     * Convert an array of attributes to a string
-     *
-     * @param array $attributes Attributes array
-     *
-     * @return string Attributes string
-     */
-
-    public function attributesToString(array $attributes):string
-    {
-        $attr_strings = [];
-
-        foreach ($attributes as $key => $value) {
-            $attr_strings[] = $key . '="' . $value . '"';
-        }
-
-        return implode(' ', $attr_strings);
-    }
-}
diff --git a/inc/managers/UploadManager2.php b/inc/managers/UploadManager2.php
deleted file mode 100644
index a322e24..0000000
--- a/inc/managers/UploadManager2.php
+++ /dev/null
@@ -1,1206 +0,0 @@
-<?php
-namespace JVBase\inc\managers;
-
-use JVBase\JVB;
-use JVBase\meta\MetaManager;
-use Exception;
-use Imagick;
-use WP_Error;
-
-if (!defined('ABSPATH')) {
-    exit; // Exit if accessed directly
-}
-/**
- * Handles file uploads for edmonton.ink dashboard
- * Includes image processing, validation, optimization, and SEO-friendly naming
- */
-class UploadManager2
-{
-	protected array $allowedTypes = [
-		//Images
-		'image/jpeg'	=> 'image',
-		'image/png'		=> 'image',
-		'image/gif'		=> 'image',
-		'image/webp'	=> 'image',
-		//Videos
-		'video/mp4'		=> 'video',
-		'video/webm'	=> 'video',
-		'video/ogg'		=> 'video',
-		'video/ogv'		=> 'video',
-		'video/quicktime'=> 'video', // .mov files
-		'video/x-msvideo'=> 'video',
-		//Documents
-		'application/pdf'		=> 'document',
-		'application/msword'	=> 'document', // .doc
-		'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'document', // .docx
-		'application/vnd.ms-excel'		=> 'document', // .xls
-		'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'document', // .xlsx
-		'text/plain'		=> 'document', // .txt
-		'text/csv'			=> 'document',
-		'application/rtf'	=> 'document'
-	];
-    /**
-     * @var array Default configuration
-     */
-	protected array $config = [
-		'allowed_types' => [
-			//Images
-			'image/jpeg',
-			'image/png',
-			'image/gif',
-			'image/webp',
-			//Videos
-			'video/mp4',
-			'video/webm',
-			'video/ogg',
-			'video/ogv',
-			'video/quicktime', // .mov files
-			'video/x-msvideo',
-			//Documents
-			'application/pdf',
-			'application/msword', // .doc
-			'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
-			'application/vnd.ms-excel', // .xls
-			'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
-			'text/plain', // .txt
-			'text/csv',
-			'application/rtf'
-		],
-		'max_size' => [
-			'image' => 5242880,    // 5MB
-			'video' => 104857600,  // 100MB
-			'document' => 10485760 // 10MB
-		],
-		//Image Specific Settings
-		'convert_to_webp' => true,
-		'webp_quality' => 80,
-		'optimize_images' => true,
-		'create_thumbnails' => true,
-		'use_imagick' => null, // Will be set in constructor
-		//Video specific settings
-		'extract_video_thumbnail' => true,
-		'video_thumbnail_time' => 0,
-		// Document-specific settings
-		'extract_document_preview' => false,
-		// General settings
-		'original_retention' => 2592000, // 30 days in seconds
-	];
-
-    protected string $post_type;
-    protected int $user_id;
-	protected int $term_id;
-    protected string $upload_dir;
-    protected string $upload_url;
-    protected object $cache;
-    protected object $notifications;
-    protected int $max_retries = 3;
-    protected int $retry_delay = 300; // 5 minutes
-
-    public function __construct($post_type, $user_id, $config = [])
-    {
-        $this->post_type = $post_type;
-        $this->user_id = $user_id;
-        $this->config = wp_parse_args($config, $this->config);
-        // Check for Imagick availability
-        $this->config['use_imagick'] = extension_loaded('imagick');
-
-        $this->setupUploadDirs();
-
-
-        // Schedule cleanup of original files
-//        if (!wp_next_scheduled('jvb_cleanup_original_uploads')) {
-//            wp_schedule_event(time(), 'daily', 'jvb_cleanup_original_uploads');
-//        }
-//        add_action('jvb_cleanup_original_uploads', [$this, 'cleanupOriginalFiles']);
-
-
-        // Track upload statistics
-        add_action('jvb_upload_complete', [$this, 'trackUploadStats']);
-        add_action('jvb_upload_failed', [$this, 'track_failed_upload']);
-    }
-
-    /**
-     * @param array $file_data
-     *
-     * @return array
-     * @throws Exception
-     */
-	public function secureUploadedFile(array $file_data): array
-	{
-		// Create a persistent temporary directory if it doesn't exist
-		$temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$this->user_id}";
-		wp_mkdir_p($temp_dir);
-
-		// Generate a unique filename for temporary storage
-		$temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']);
-		$temp_path = "{$temp_dir}/{$temp_filename}";
-
-		// Move the uploaded file to our temporary storage
-		if (!move_uploaded_file($file_data['tmp_name'], $temp_path)) {
-			// Fallback to copy if needed
-			if (!copy($file_data['tmp_name'], $temp_path)) {
-				throw new Exception('Failed to store uploaded file');
-			}
-		}
-
-		// Return metadata about the stored file
-		return [
-			'temp_path' => $temp_path,
-			'original_name' => $file_data['name'],
-			'mime_type' => $file_data['type'],
-			'file_size' => $file_data['size'],
-			'stored_at' => current_time('mysql')
-		];
-	}
-
-	/**
-	 * Process file from secured storage
-	 */
-	public function processFileFromStorage(string $temp_path, string $post_type, int $post_id, int $term_id, string $original_filename): array|WP_Error
-	{
-		if (!file_exists($temp_path)) {
-			throw new Exception('Temporary file no longer exists: ' . $temp_path);
-		}
-
-		$this->term_id = $term_id;
-		$this->post_type = $post_type;
-
-		$rel_path = $this->getUploadDirectory();
-		$base_filename = $this->generateFilename($original_filename, get_userdata($this->user_id));
-
-		return $this->processFile($temp_path, $rel_path, $base_filename, $post_id);
-	}
-
-	/**
-	 * Main entry point - detects file type and routes to appropriate processor
-	 * @throws Exception
-	 */
-	public function processFile(string $file_path, string $rel_path, string $filename, int $post_id): array|WP_Error
-	{
-		if (!file_exists($file_path)) {
-			throw new Exception('File no longer exists: ' . $file_path);
-		}
-
-		$mime_type = mime_content_type($file_path);
-		if (!in_array($mime_type, $this->config['allowed_types'])) {
-			throw new Exception('Invalid file type: ' . $mime_type);
-		}
-
-		$file_type = (array_key_exists($mime_type, $this->allowedTypes)) ? $this->allowedTypes[$mime_type] : 'unknown';
-
-		// Route to appropriate processor
-		switch ($file_type) {
-			case 'image':
-				return $this->processImage($file_path, $rel_path, $filename, $post_id);
-			case 'video':
-				return $this->processVideo($file_path, $rel_path, $filename, $post_id);
-			case 'document':
-				return $this->processDocument($file_path, $rel_path, $filename, $post_id);
-			default:
-				throw new Exception('Unknown file type category');
-		}
-	}
-
-	/**
-	 * Validate file size based on type
-	 */
-	protected function validateFileSize(string $file_path, string $file_type): bool
-	{
-		$file_size = filesize($file_path);
-		$max_size = $this->config['max_size'][$file_type] ?? $this->config['max_size']['image'];
-
-		if ($file_size > $max_size) {
-			throw new Exception(sprintf(
-				'File size (%s) exceeds maximum allowed size (%s) for %s files',
-				size_format($file_size),
-				size_format($max_size),
-				$file_type
-			));
-		}
-
-		return true;
-	}
-
-    /**
-     * @param string $temp_path
-     * @param string $post_type
-     * @param int $post_id
-     * @param int $term_id
-     * @param string $original_filename
-     *
-     * @return array
-     * @throws Exception
-     */
-	public function processImageFromStorage(string $temp_path, string $post_type, int $post_id = 0, int $term_id = 0, string $original_filename = ''): array
-	{
-		if (!file_exists($temp_path)) {
-			throw new Exception('Stored file no longer exists: ' . $temp_path);
-		}
-
-		$this->term_id = $term_id;
-		$this->post_type = $post_type;
-
-		// Get base upload directory based on content type
-		$rel_path = $this->getUploadDirectory();
-
-		// Ensure the directory exists
-		$full_upload_path = $this->upload_dir . '/' . $rel_path;
-		if (!wp_mkdir_p($full_upload_path)) {
-			throw new Exception("Failed to create upload directory: {$full_upload_path}");
-		}
-
-		// Generate filename WITHOUT extension (generateFilename should not include extension)
-		$base_filename = $this->generateFilename($original_filename, get_userdata($this->user_id));
-
-		// Process the image directly from our temporary storage
-		return $this->processImage($temp_path, $rel_path, $base_filename, $post_id);
-	}
-
-    /**
-     * @return void
-     * @throws Exception
-     */
-    protected function setupUploadDirs():void
-    {
-        $upload_info = wp_upload_dir();
-        if ($upload_info['error']) {
-            throw new Exception($upload_info['error']);
-        }
-
-        $this->upload_dir = $upload_info['basedir'];
-        $this->upload_url = $upload_info['baseurl'];
-    }
-
-    /**
-     * @return string
-     */
-    public function getUploadDirectory():string
-    {
-		// Default WordPress organization: year/month
-		$default_path = date('Y/m');
-
-		/**
-		 * Filter the upload directory structure
-		 *
-		 * @param string $path The default upload path
-		 * @param string $post_type The post type being uploaded
-		 * @param int $user_id The user ID
-		 * @param int $term_id The term ID (if applicable)
-		 */
-		return apply_filters('jvb_upload_directory', $default_path, $this->post_type, $this->user_id, $this->term_id);
-    }
-
-	/**
-	 * Generate filename with extensible filtering
-	 * Default: generic SEO-friendly format
-	 *
-	 * @param string $original_name
-	 * @param object $user_data
-	 * @return string
-	 */
-	public function generateFilename(string $original_name, object $user_data): string
-	{
-		// Default generic filename: {post_type}-{user_id}-{counter}
-		$filename = sprintf(
-			'%s-%s',
-			sanitize_title($this->post_type),
-			$this->user_id
-		);
-
-		/**
-		 * Filter the generated filename (without extension)
-		 *
-		 * @param string $filename The generated filename
-		 * @param string $original_name The original filename
-		 * @param object $user_data WordPress user data object
-		 * @param string $post_type The post type
-		 * @param int $user_id The user ID
-		 * @param int $term_id The term ID (if applicable)
-		 */
-		return apply_filters('jvb_upload_filename', $filename, $original_name, $user_data, $this->post_type, $this->user_id, $this->term_id).'-'.$this->getNextFileCounter();
-	}
-
-    /**
-     * @return string
-     */
-	protected function getNextFileCounter(): string
-	{
-		// Get counter key for this post type
-		$counter_key = 'upload_counter_' . str_replace(['/', '\\'], '_', $this->post_type);
-
-		// Get current counter value, default to 0
-		$counter = (int)get_user_meta($this->user_id, $counter_key, true) ?: 0;
-
-		// Increment counter
-		$counter++;
-
-		// Update counter in user meta
-		update_user_meta($this->user_id, $counter_key, $counter);
-
-		// Return formatted counter
-		return sprintf('%08d', $counter);
-	}
-
-
-	/**
-	 * Generate alt text with filtering for customization
-	 * Default: basic or empty alt text
-	 *
-	 * @param string $file
-	 * @param object $user_data
-	 * @param int|null $post_id
-	 * @return string
-	 */
-	protected function generateAltText(string $file, object $user_data, int|null $post_id = null): string
-	{
-		// Default: basic alt text or empty
-		$alt_text = '';
-
-		/**
-		 * Filter the generated alt text
-		 *
-		 * @param string $alt_text The generated alt text
-		 * @param string $file The file path
-		 * @param object $user_data WordPress user data object
-		 * @param int|null $post_id The post ID (if applicable)
-		 * @param string $post_type The post type
-		 * @param int $user_id The user ID
-		 * @param int $term_id The term ID (if applicable)
-		 */
-		return apply_filters('jvb_upload_alt_text', $alt_text, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
-	}
-
-	/**
-	 * Generate image title with filtering for customization
-	 * Default: WordPress default behavior (filename-based)
-	 *
-	 * @param string $file
-	 * @param object $user_data
-	 * @param int|null $post_id
-	 * @return string
-	 */
-	protected function generateImageTitle(string $file, object $user_data, int|null $post_id = null): string
-	{
-		// Default: Use filename without extension (WordPress default behavior)
-		$title = pathinfo($file, PATHINFO_FILENAME);
-
-		/**
-		 * Filter the generated image title
-		 *
-		 * @param string $title The generated title
-		 * @param string $file The file path
-		 * @param object $user_data WordPress user data object
-		 * @param int|null $post_id The post ID (if applicable)
-		 * @param string $post_type The post type
-		 * @param int $user_id The user ID
-		 * @param int $term_id The term ID (if applicable)
-		 */
-		return apply_filters('jvb_upload_image_title', $title, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
-	}
-
-    /**
-     * @param array $file_data
-     * @param array $options
-     *
-     * @return array|WP_Error
-     */
-	public function handleContentUpload(array $file_data, array $options = []): array|WP_Error
-	{
-		try {
-			if (!isset($file_data['tmp_name']) || !is_uploaded_file($file_data['tmp_name'])) {
-				throw new Exception('No valid file uploaded.');
-			}
-
-			$user_data = get_userdata($this->user_id);
-			$post_id = $options['post_id'] ?? 0;
-			$this->term_id = $options['term_id'] ?? 0;
-
-			// Get base upload directory based on content type
-			$base_dir = $this->getUploadDirectory();
-			$original_dir = 'originals';
-
-			$rel_path = $base_dir;
-			$this->ensureUploadDirs($rel_path, $original_dir);
-
-			$filename = $this->generateFilename($file_data['name'], $user_data);
-
-			// Store original file
-			$original_file = $this->storeOriginalFile(
-				$file_data['tmp_name'],
-				$base_dir,
-				$original_dir,
-				$filename
-			);
-
-			// Process immediately
-			return $this->processImage($original_file, $rel_path, $filename, $post_id);
-
-		} catch (Exception $e) {
-			return new WP_Error('upload_failed', $e->getMessage());
-		}
-	}
-
-    /**
-     * @param string $rel_path
-     * @param string $original_dir
-     *
-     * @return void
-     * @throws Exception
-     */
-	protected function ensureUploadDirs($rel_path, $original_dir): void
-	{
-		$dirs = [
-			"{$this->upload_dir}/{$rel_path}",
-			"{$this->upload_dir}/{$rel_path}/{$original_dir}"
-		];
-
-		foreach ($dirs as $dir) {
-			if (!wp_mkdir_p($dir)) {
-				throw new Exception("Failed to create directory: {$dir}");
-			}
-		}
-	}
-
-
-	/**
-	 * @param string $tmp_file
-	 * @param string $base_dir
-	 * @param string $original_dir
-	 * @param string $filename
-	 * @return string
-	 * @throws Exception
-	 */
-	protected function storeOriginalFile(string $tmp_file, string $base_dir, string $original_dir, string $filename): string
-	{
-		$ext = pathinfo($tmp_file, PATHINFO_EXTENSION);
-		$original_path = "{$this->upload_dir}/{$base_dir}/{$original_dir}/{$filename}.{$ext}";
-
-		if (!move_uploaded_file($tmp_file, $original_path)) {
-			throw new Exception('Failed to store original file');
-		}
-
-		return $original_path;
-	}
-
-	/**
-	 * Process image with better error handling
-	 * @throws Exception
-	 */
-	protected function processImage(string $original_file, string $rel_path, string $filename, int $post_id): array
-	{
-		// Validate the original file still exists
-		if (!file_exists($original_file)) {
-			throw new Exception('Original file no longer exists: ' . $original_file);
-		}
-		$this->validateFileSize($original_file, 'image');
-
-		// Verify file type before processing
-		$mime_type = mime_content_type($original_file);
-		if (!in_array($mime_type, $this->config['allowed_types'])) {
-			throw new Exception('Invalid file type detected during processing: ' . $mime_type);
-		}
-
-		// Ensure the upload directory exists
-		$full_upload_dir = $this->upload_dir . '/' . $rel_path;
-		if (!wp_mkdir_p($full_upload_dir)) {
-			throw new Exception("Failed to create upload directory: {$full_upload_dir}");
-		}
-
-		// Convert to WebP if enabled
-		if ($this->config['convert_to_webp'] && $mime_type !== 'image/webp') {
-			$final_path = "{$full_upload_dir}/{$filename}.webp";
-
-			if ($this->config['use_imagick']) {
-				$this->convertWithImagick($original_file, $final_path);
-			} else {
-				$this->convertWithGd($original_file, $final_path);
-			}
-		} else {
-			// Just copy the original with its extension
-			$original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
-			$final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
-
-			if (!copy($original_file, $final_path)) {
-				throw new Exception("Failed to copy file from {$original_file} to {$final_path}");
-			}
-		}
-
-		// Verify the final file was created
-		if (!file_exists($final_path)) {
-			throw new Exception("Final processed file was not created: {$final_path}");
-		}
-
-		// Generate title text
-		$title = $this->generateImageTitle(
-			$final_path,
-			get_userdata($this->user_id),
-			$post_id
-		);
-
-		// Create attachment with title
-		$attachment_id = $this->createAttachment($final_path, $title, $post_id);
-
-		// Generate thumbnails
-		if ($this->config['create_thumbnails']) {
-			$this->generateThumbnails($attachment_id);
-		}
-
-		// Update post attachments with new file info
-		$this->updatePostAttachments($attachment_id, $final_path);
-
-		return [
-			'success' => true,
-			'attachment_id' => $attachment_id,
-			'url' => wp_get_attachment_url($attachment_id),
-			'file' => $final_path
-		];
-	}
-
-    /**
-     * @param string $source
-     * @param string $destination
-     *
-     * @return void
-     * @throws Exception
-     */
-	protected function convertWithImagick(string $source, string $destination, string $toType = 'webp'): void
-	{
-		$allowed = ['webp', 'jpeg', 'jpg', 'png'];
-		if (!in_array($toType, $allowed)) {
-			return;
-		}
-		try {
-			$image = new Imagick($source);
-			$image->setImageFormat('webp');
-			$image->setImageCompressionQuality($this->config['webp_quality']);
-			$image->writeImage($destination);
-			$image->clear();
-		} catch (Exception $e) {
-			throw new Exception('WebP conversion with Imagick failed: ' . $e->getMessage());
-		}
-	}
-
-    /**
-     * Fixed convertWithGd method with better error handling
-     */
-	protected function convertWithGd(string $source, string $destination, string $toType = 'webp'): void
-	{
-		$mime_type = mime_content_type($source);
-
-		// Ensure destination directory exists
-		$dest_dir = dirname($destination);
-		if (!wp_mkdir_p($dest_dir)) {
-			throw new Exception("Failed to create destination directory: {$dest_dir}");
-		}
-
-		switch ($mime_type) {
-			case 'image/webp':
-				$image = imagecreatefromwebp($source);
-				break;
-			case 'image/jpeg':
-				$image = imagecreatefromjpeg($source);
-				break;
-			case 'image/png':
-				$image = imagecreatefrompng($source);
-				if ($image !== false) {
-					imagepalettetotruecolor($image);
-					imagealphablending($image, true);
-					imagesavealpha($image, true);
-				}
-				break;
-			case 'image/gif':
-				$image = imagecreatefromgif($source);
-				if ($image !== false) {
-					imagepalettetotruecolor($image);
-				}
-				break;
-			default:
-				throw new Exception('Unsupported image type for GD conversion: ' . $mime_type);
-		}
-
-		if ($image === false) {
-			throw new Exception('Failed to create image resource from source file: ' . $source);
-		}
-
-
-		// Convert to WebP
-		$result = imagewebp($image, $destination, $this->config['webp_quality']);
-
-		// Clean up memory
-		imagedestroy($image);
-
-		if (!$result) {
-			throw new Exception('WebP conversion with GD failed - imagewebp returned false');
-		}
-
-		// Verify the file was actually created
-		if (!file_exists($destination)) {
-			throw new Exception('WebP file was not created despite imagewebp returning true');
-		}
-	}
-
-	/**
-	 * @return void
-	 */
-	public function cleanupOriginalFiles(): void
-	{
-		$cutoff = time() - $this->config['original_retention'];
-
-		// Get upload directory and find original directories
-		$pattern = "{$this->upload_dir}/**/originals";
-		$original_dirs = glob($pattern, GLOB_ONLYDIR);
-
-		foreach ($original_dirs as $original_dir) {
-			$files = glob("{$original_dir}/*");
-			foreach ($files as $file) {
-				if (filemtime($file) < $cutoff) {
-					unlink($file);
-				}
-			}
-		}
-	}
-
-	/**
-	 * @param string|null $temp_dir
-	 * @return void
-	 */
-	protected function cleanupTempFiles(string|null $temp_dir = null): void
-	{
-		if (is_null($temp_dir)) {
-			$temp_dir = $this->upload_dir . '/tmp';
-		}
-
-		if (is_dir($temp_dir)) {
-			$files = glob($temp_dir . '/*');
-			foreach ($files as $file) {
-				if (is_file($file)) {
-					@unlink($file);
-				}
-			}
-			@rmdir($temp_dir);
-		}
-	}
-
-
-	/**
-	 * @param int $retention_days
-	 * @return void
-	 */
-	public function cleanupUserFiles(int $retention_days = 30): void
-	{
-		$cutoff = time() - ($retention_days * DAY_IN_SECONDS);
-		$user_dir = $this->getUploadDirectory();
-
-		if (!is_dir($user_dir)) {
-			return;
-		}
-
-		$this->cleanupDirectory($user_dir, $cutoff);
-	}
-
-    /**
-	 * @param string $dir
-	 * @param int $cutoff
-	 * @return void
-	 */
-	protected function cleanupDirectory(string $dir, int $cutoff): void
-	{
-		$files = glob($dir . '/*');
-		foreach ($files as $file) {
-			if (is_dir($file)) {
-				$this->cleanupDirectory($file, $cutoff);
-			} elseif (filemtime($file) < $cutoff) {
-				@unlink($file);
-			}
-		}
-	}
-
-	/**
-	 * @param string $file
-	 * @param string $title
-	 * @param int $post_id
-	 * @return int|WP_Error
-	 * @throws Exception
-	 */
-	protected function createAttachment(string $file, string $title, int $post_id): int|WP_Error
-	{
-		$file_url = str_replace($this->upload_dir, $this->upload_url, $file);
-
-		$attachment = [
-			'post_mime_type' => mime_content_type($file),
-			'post_title' => $title,
-			'post_name' => sanitize_title($title),
-			'post_content' => '',
-			'post_status' => 'inherit',
-			'guid' => $file_url
-		];
-
-		$attach_id = wp_insert_attachment($attachment, $file, $post_id);
-		if (is_wp_error($attach_id)) {
-			throw new Exception($attach_id->get_error_message());
-		}
-
-		// Generate and set alt text for images only
-		if (str_starts_with(mime_content_type($file), 'image/')) {
-			$alt_text = $this->generateAltText($file, get_userdata($this->user_id), $post_id);
-			update_post_meta($attach_id, '_wp_attachment_image_alt', $alt_text);
-		}
-
-		return $attach_id;
-	}
-
-	/**
-	 * Generate thumbnails using WordPress's built-in image size system
-	 * This will create all registered image sizes (thumbnail, medium, large, and any custom sizes)
-	 * Sites can register custom image sizes using add_image_size()
-	 *
-	 * @param int $attachment_id
-	 * @return void
-	 */
-	protected function generateThumbnails(int $attachment_id): void
-	{
-		require_once(ABSPATH . 'wp-admin/includes/image.php');
-		$metadata = wp_generate_attachment_metadata($attachment_id, get_attached_file($attachment_id));
-		wp_update_attachment_metadata($attachment_id, $metadata);
-	}
-
-	/**
-	 * @param $result
-	 * @return void
-	 */
-	protected function trackUploadStats($result)
-	{
-		$stats_key = "upload_stats_{$this->user_id}";
-		$stats = wp_cache_get($stats_key) ?: [
-			'total_uploads' => 0,
-			'successful_uploads' => 0,
-			'failed_uploads' => 0,
-			'total_size' => 0,
-			'last_upload' => null
-		];
-
-		if ($result['success']) {
-			$stats['successful_uploads']++;
-			$stats['total_size'] += filesize($result['file']);
-		} else {
-			$stats['failed_uploads']++;
-		}
-
-		$stats['total_uploads']++;
-		$stats['last_upload'] = current_time('mysql');
-
-		wp_cache_set($stats_key, $stats, '', DAY_IN_SECONDS);
-	}
-
-	/**
-	 * @param int $attachment_id
-	 * @param string $new_file_path
-	 * @return void
-	 */
-	protected function updatePostAttachments(int $attachment_id, string $new_file_path): void
-	{
-		// Update attachment post
-		$file_url = str_replace($this->upload_dir, $this->upload_url, $new_file_path);
-		$filename = basename($new_file_path);
-
-		wp_update_post([
-			'ID' => $attachment_id,
-			'guid' => $file_url,
-			'post_mime_type' => mime_content_type($new_file_path),
-			'post_title' => $filename
-		]);
-
-		// Update attachment metadata
-		update_post_meta($attachment_id, '_wp_attached_file', str_replace($this->upload_dir . '/', '', $new_file_path));
-
-		// Update attachment metadata including sizes
-		$metadata = wp_get_attachment_metadata($attachment_id);
-		if ($metadata) {
-			$metadata['file'] = str_replace($this->upload_dir . '/', '', $new_file_path);
-
-			// Update thumbnail paths if they exist
-			if (!empty($metadata['sizes'])) {
-				foreach ($metadata['sizes'] as $size => $info) {
-					$old_file = $info['file'];
-					$new_file = preg_replace(
-						'/\.(jpe?g|png|gif)$/i',
-						'.webp',
-						$old_file
-					);
-					$metadata['sizes'][$size]['file'] = $new_file;
-					$metadata['sizes'][$size]['mime-type'] = 'image/webp';
-				}
-			}
-
-			wp_update_attachment_metadata($attachment_id, $metadata);
-		}
-
-		// If this is a profile/featured image, update those references
-		$post_id = wp_get_post_parent_id($attachment_id);
-		if ($post_id) {
-			$featured_image_id = get_post_thumbnail_id($post_id);
-			if ($featured_image_id === $attachment_id) {
-				// Re-set the featured image to trigger any necessary updates
-				set_post_thumbnail($post_id, $attachment_id);
-			}
-		}
-
-		// Clear any caches
-		clean_attachment_cache($attachment_id);
-		clean_post_cache($post_id);
-	}
-
-	/**
-	 * Clean up empty temporary directories
-	 *
-	 * @param int $user_id
-	 * @return void
-	 */
-	public function cleanupEmptyTempDirs(int $user_id): void
-	{
-		$temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$user_id}";
-
-		if (is_dir($temp_dir)) {
-			// Check if directory is empty
-			$files = scandir($temp_dir);
-			$is_empty = (count($files) <= 2); // Only . and .. entries
-
-			if ($is_empty) {
-				// Try to remove the empty directory
-				@rmdir($temp_dir);
-
-				// Also check if parent temp directory is empty
-				$parent_temp_dir = "{$this->upload_dir}/jvb_temp_uploads";
-				$parent_files = scandir($parent_temp_dir);
-				$parent_is_empty = (count($parent_files) <= 2); // Only . and .. entries
-
-				if ($parent_is_empty) {
-					@rmdir($parent_temp_dir);
-				}
-			}
-		}
-	}
-
-	/**
-	 * Process video files
-	 */
-	protected function processVideo(string $original_file, string $rel_path, string $filename, int $post_id): array
-	{
-		$this->validateFileSize($original_file, self::TYPE_VIDEO);
-
-		$full_upload_dir = $this->upload_dir . '/' . $rel_path;
-		if (!wp_mkdir_p($full_upload_dir)) {
-			throw new Exception("Failed to create upload directory: {$full_upload_dir}");
-		}
-
-		// Keep original extension for videos
-		$original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
-		$final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
-
-		if (!copy($original_file, $final_path)) {
-			throw new Exception("Failed to copy video file");
-		}
-
-		if (!file_exists($final_path)) {
-			throw new Exception("Final video file was not created: {$final_path}");
-		}
-
-		// Generate title
-		$title = $this->generateMediaTitle($final_path, get_userdata($this->user_id), $post_id, 'video');
-		$attachment_id = $this->createAttachment($final_path, $title, $post_id);
-
-		// Extract video metadata
-		$video_metadata = $this->extractVideoMetadata($final_path);
-		if ($video_metadata) {
-			update_post_meta($attachment_id, '_jvb_video_metadata', $video_metadata);
-		}
-
-		// Generate video thumbnail if enabled
-		if ($this->config['extract_video_thumbnail']) {
-			$thumbnail_id = $this->generateVideoThumbnail($final_path, $attachment_id, $post_id);
-			if ($thumbnail_id) {
-				update_post_meta($attachment_id, '_jvb_video_thumbnail', $thumbnail_id);
-			}
-		}
-
-		return [
-			'success' => true,
-			'type' => self::TYPE_VIDEO,
-			'attachment_id' => $attachment_id,
-			'url' => wp_get_attachment_url($attachment_id),
-			'file' => $final_path,
-			'metadata' => $video_metadata ?? null
-		];
-	}
-
-	/**
-	 * Process document files
-	 */
-	protected function processDocument(string $original_file, string $rel_path, string $filename, int $post_id): array
-	{
-		$this->validateFileSize($original_file, self::TYPE_DOCUMENT);
-
-		$full_upload_dir = $this->upload_dir . '/' . $rel_path;
-		if (!wp_mkdir_p($full_upload_dir)) {
-			throw new Exception("Failed to create upload directory: {$full_upload_dir}");
-		}
-
-		// Keep original extension for documents
-		$original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
-		$final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
-
-		if (!copy($original_file, $final_path)) {
-			throw new Exception("Failed to copy document file");
-		}
-
-		if (!file_exists($final_path)) {
-			throw new Exception("Final document file was not created: {$final_path}");
-		}
-
-		// Generate title
-		$title = $this->generateMediaTitle($final_path, get_userdata($this->user_id), $post_id, 'document');
-		$attachment_id = $this->createAttachment($final_path, $title, $post_id);
-
-		// Extract document metadata
-		$doc_metadata = $this->extractDocumentMetadata($final_path);
-		if ($doc_metadata) {
-			update_post_meta($attachment_id, '_jvb_document_metadata', $doc_metadata);
-		}
-
-		return [
-			'success' => true,
-			'type' => self::TYPE_DOCUMENT,
-			'attachment_id' => $attachment_id,
-			'url' => wp_get_attachment_url($attachment_id),
-			'file' => $final_path,
-			'metadata' => $doc_metadata ?? null
-		];
-	}
-
-	/**
-	 * Extract video metadata using getID3 or FFmpeg if available
-	 */
-	protected function extractVideoMetadata(string $file_path): ?array
-	{
-		$metadata = [
-			'filesize' => filesize($file_path),
-			'mime_type' => mime_content_type($file_path)
-		];
-
-		// Try FFmpeg first (more reliable)
-		if ($this->hasFFmpeg()) {
-			$ffmpeg_data = $this->getVideoMetadataFFmpeg($file_path);
-			if ($ffmpeg_data) {
-				return array_merge($metadata, $ffmpeg_data);
-			}
-		}
-
-		// Fallback to getID3 if available
-		if (class_exists('getID3')) {
-			$getID3 = new \getID3();
-			$file_info = $getID3->analyze($file_path);
-
-			if (isset($file_info['video'])) {
-				$metadata['duration'] = $file_info['playtime_seconds'] ?? null;
-				$metadata['width'] = $file_info['video']['resolution_x'] ?? null;
-				$metadata['height'] = $file_info['video']['resolution_y'] ?? null;
-				$metadata['codec'] = $file_info['video']['codec'] ?? null;
-				$metadata['bitrate'] = $file_info['bitrate'] ?? null;
-			}
-		}
-
-		return $metadata;
-	}
-
-	/**
-	 * Get video metadata using FFmpeg
-	 */
-	protected function getVideoMetadataFFmpeg(string $file_path): ?array
-	{
-		$ffprobe_path = $this->getFFprobePath();
-		if (!$ffprobe_path) {
-			return null;
-		}
-
-		$command = sprintf(
-			'%s -v quiet -print_format json -show_format -show_streams %s',
-			escapeshellarg($ffprobe_path),
-			escapeshellarg($file_path)
-		);
-
-		$output = shell_exec($command);
-		if (!$output) {
-			return null;
-		}
-
-		$data = json_decode($output, true);
-		if (!$data) {
-			return null;
-		}
-
-		$metadata = [];
-
-		// Get duration
-		if (isset($data['format']['duration'])) {
-			$metadata['duration'] = (float) $data['format']['duration'];
-		}
-
-		// Get video stream info
-		foreach ($data['streams'] ?? [] as $stream) {
-			if ($stream['codec_type'] === 'video') {
-				$metadata['width'] = $stream['width'] ?? null;
-				$metadata['height'] = $stream['height'] ?? null;
-				$metadata['codec'] = $stream['codec_name'] ?? null;
-				$metadata['bitrate'] = $stream['bit_rate'] ?? null;
-				break;
-			}
-		}
-
-		return $metadata;
-	}
-
-	/**
-	 * Check if FFmpeg is available
-	 */
-	protected function hasFFmpeg(): bool
-	{
-		return $this->getFFprobePath() !== null;
-	}
-
-	/**
-	 * Get FFprobe path (companion tool to FFmpeg)
-	 */
-	protected function getFFprobePath(): ?string
-	{
-		$paths = ['ffprobe', '/usr/bin/ffprobe', '/usr/local/bin/ffprobe'];
-
-		foreach ($paths as $path) {
-			if (shell_exec("which {$path}")) {
-				return $path;
-			}
-		}
-
-		return null;
-	}
-
-	/**
-	 * Generate video thumbnail
-	 */
-	protected function generateVideoThumbnail(string $video_path, int $video_attachment_id, int $post_id): ?int
-	{
-		if (!$this->hasFFmpeg()) {
-			return null;
-		}
-
-		$ffmpeg_path = str_replace('ffprobe', 'ffmpeg', $this->getFFprobePath());
-		if (!$ffmpeg_path) {
-			return null;
-		}
-
-		// Generate thumbnail filename
-		$thumbnail_dir = dirname($video_path) . '/thumbnails';
-		wp_mkdir_p($thumbnail_dir);
-
-		$thumbnail_filename = pathinfo($video_path, PATHINFO_FILENAME) . '-thumb.jpg';
-		$thumbnail_path = $thumbnail_dir . '/' . $thumbnail_filename;
-
-		// Extract frame at specified time
-		$time = $this->config['video_thumbnail_time'];
-		$command = sprintf(
-			'%s -i %s -ss %d -vframes 1 -q:v 2 %s 2>&1',
-			escapeshellarg($ffmpeg_path),
-			escapeshellarg($video_path),
-			$time,
-			escapeshellarg($thumbnail_path)
-		);
-
-		shell_exec($command);
-
-		if (!file_exists($thumbnail_path)) {
-			return null;
-		}
-
-		// Create attachment for thumbnail
-		$title = get_the_title($video_attachment_id) . ' - Thumbnail';
-		$thumbnail_id = $this->createAttachment($thumbnail_path, $title, $post_id);
-
-		return $thumbnail_id;
-	}
-
-	/**
-	 * Extract document metadata
-	 */
-	protected function extractDocumentMetadata(string $file_path): array
-	{
-		$metadata = [
-			'filesize' => filesize($file_path),
-			'mime_type' => mime_content_type($file_path),
-			'extension' => pathinfo($file_path, PATHINFO_EXTENSION)
-		];
-
-		// PDF-specific metadata
-		if ($metadata['mime_type'] === 'application/pdf') {
-			$pdf_metadata = $this->extractPdfMetadata($file_path);
-			if ($pdf_metadata) {
-				$metadata = array_merge($metadata, $pdf_metadata);
-			}
-		}
-
-		return $metadata;
-	}
-
-	/**
-	 * Extract PDF metadata
-	 */
-	protected function extractPdfMetadata(string $file_path): ?array
-	{
-		$metadata = [];
-
-		// Try using pdfinfo if available
-		if (shell_exec('which pdfinfo')) {
-			$output = shell_exec('pdfinfo ' . escapeshellarg($file_path));
-			if ($output) {
-				if (preg_match('/Pages:\s+(\d+)/', $output, $matches)) {
-					$metadata['pages'] = (int) $matches[1];
-				}
-				if (preg_match('/Title:\s+(.+)/', $output, $matches)) {
-					$metadata['title'] = trim($matches[1]);
-				}
-				if (preg_match('/Author:\s+(.+)/', $output, $matches)) {
-					$metadata['author'] = trim($matches[1]);
-				}
-			}
-		}
-
-		// Fallback to basic file parsing if pdfinfo not available
-		if (empty($metadata)) {
-			$content = file_get_contents($file_path, false, null, 0, 1024);
-			if (preg_match('/\/Count\s+(\d+)/', $content, $matches)) {
-				$metadata['pages'] = (int) $matches[1];
-			}
-		}
-
-		return $metadata ?: null;
-	}
-
-	/**
-	 * Generate media title (for videos and documents)
-	 */
-	protected function generateMediaTitle(string $file_path, object $user_data, int $post_id, string $type): string
-	{
-		$filename = pathinfo($file_path, PATHINFO_FILENAME);
-		$post_title = $post_id ? get_the_title($post_id) : '';
-
-		$title = sprintf(
-			'%s %s by %s',
-			ucfirst($type),
-			$post_title ? "for {$post_title}" : $filename,
-			$user_data->display_name
-		);
-
-		/**
-		 * Filter the generated media title
-		 */
-		return apply_filters('jvb_media_title', $title, $file_path, $user_data, $post_id, $type);
-	}
-}
diff --git a/inc/managers/UserTermsManager.php b/inc/managers/UserTermsManager.php
index b900dff..4673568 100644
--- a/inc/managers/UserTermsManager.php
+++ b/inc/managers/UserTermsManager.php
@@ -1,8 +1,6 @@
 <?php
 namespace JVBase\managers;
 
-use JVBase\JVB;
-use JVBase\managers\CacheManager;
 use WP_Post;
 use WP_Error;
 use Exception;
@@ -13,7 +11,7 @@
 class UserTermsManager
 {
     private string $table_name;
-    private CacheManager $cache;
+    private Cache $cache;
     private string $cacheGroup  = 'user_terms_';
     private int $ttl = DAY_IN_SECONDS; // 1 day default
 	protected \wpdb $wpdb;
@@ -41,12 +39,8 @@
      */
     public function clearUserCache(int $user_id, string|null $taxonomy = null):void
     {
-		$cache = CacheManager::for($user_id.'_term_relationships');
-        if ($taxonomy) {
-            $cache->delete(jvbNoBase($taxonomy));
-        } else {
-			$cache->invalidate();
-        }
+		$cache = Cache::for($user_id.'_term_relationships', DAY_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
+		$cache->flush();
     }
 
     // Update term usage when a post is saved
@@ -592,10 +586,9 @@
     private function fetchUserTerms(int $user_id, string $taxonomy, array $args):array
     {
         $taxonomy = jvbCheckBase($taxonomy);
-		$cache = CacheManager::for($user_id.'_term_relationships');
+		$cache = Cache::for($user_id.'_term_relationships', DAY_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
         $key = $cache->generateKey(array_merge(
             [
-                'user'      => $user_id,
                 'taxonomy'  => $taxonomy,
             ],
             $args
@@ -683,59 +676,4 @@
         }, $terms);
     }
 
-    /**
-     * @param int $user_id
-     *
-     * @return bool
-     */
-    public function warmCache(int $user_id):bool
-    {
-        // Get all taxonomies
-        $taxonomies = getTaxonomies(['_builtin' => false], 'names');
-
-        foreach ($taxonomies as $taxonomy) {
-            if (str_starts_with($taxonomy, BASE)) {
-                // Pre-cache the most common queries
-                $common_args = [
-                    // Most frequently used terms
-                    [
-                        'orderby' => 'count',
-                        'order' => 'DESC',
-                        'limit' => 20,
-                        'include_parents' => true
-                    ],
-                    // Recently used terms
-                    [
-                        'orderby' => 'last_used',
-                        'order' => 'DESC',
-                        'limit' => 20,
-                        'include_parents' => true
-                    ],
-                    // Alphabetical list
-                    [
-                        'orderby' => 'name',
-                        'order' => 'ASC',
-                        'limit' => 0,
-                        'include_parents' => true
-                    ],
-                    // Direct terms only (no parents)
-                    [
-                        'orderby' => 'count',
-                        'order' => 'DESC',
-                        'limit' => 0,
-                        'only_direct' => true
-                    ]
-                ];
-
-                foreach ($common_args as $args) {
-                    // Force skip_cache to ensure we get fresh data
-                    $args['skip_cache'] = true;
-
-                    // Warm the cache by executing the query
-                    $this->getUserTerms($user_id, $taxonomy, $args);
-                }
-            }
-        }
-        return true;
-    }
 }
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index 84cad06..27b90b7 100644
--- a/inc/managers/_setup.php
+++ b/inc/managers/_setup.php
@@ -1,15 +1,21 @@
 <?php
 
+use JVBase\managers\Cache;
 use JVBase\managers\IconsManager;
 use JVBase\utility\Features;
 
-
 require(JVB_DIR . '/inc/managers/ScriptLoader.php');
-require(JVB_DIR . '/inc/managers/CacheManager.php');
+//require(JVB_DIR . '/inc/managers/CacheManager.php');
+require(JVB_DIR . '/inc/managers/Cache.php');
+class_alias('JVBase\managers\Cache', 'JVBase\managers\CacheManager');
+
+
 require(JVB_DIR . '/inc/managers/IconsManager.php');
-add_action('init', 'jvbInitIconsManager', 1); // Priority 1 - very early
-function jvbInitIconsManager(): void
+add_action('init', 'jvbInit', 1); // Priority 1 - very early
+function jvbInit(): void
 {
+
+	Cache::registerHooks();
 	// Initialize base sources (this registers hooks and includes defaults)
 	IconsManager::for('icons');
 	IconsManager::for('forms');
@@ -27,7 +33,7 @@
 if (Features::forSite()->has('magicLink')) {
 	require(JVB_DIR . '/inc/managers/MagicLinkManager.php');
 }
-require(JVB_DIR . '/inc/managers/AjaxRateLimiter.php');
+
 require(JVB_DIR . '/inc/managers/LoginManager.php');
 
 
diff --git a/inc/managers/queue/Queue.php b/inc/managers/queue/Queue.php
index 66ceed0..ce213f2 100644
--- a/inc/managers/queue/Queue.php
+++ b/inc/managers/queue/Queue.php
@@ -61,6 +61,36 @@
 		try {
 			$incoming = $this->buildOperation($type, $userId, $data, $options);
 			$mergeable = $this->registry->getMergeable($type);
+			$existingById = $this->storage->find($incoming->id);
+
+			if ($existingById) {
+				// Operation with this ID already exists
+				if (in_array($existingById->state, ['pending', 'scheduled']) && $mergeable) {
+					// Still pending and mergeable, merge into it
+					$merged = $mergeable->merge($existingById, $incoming);
+					$this->storage->save($merged);
+					$this->runQueueOnShutdown();
+
+					return [
+						'success'          => true,
+						'operation_id'     => $merged->id,
+						'updated_existing' => true,
+					];
+				} else {
+					// Already processing/completed, or not mergeable - generate new ID
+					$incoming->id = 'u' . $userId . '_' . time() . '_' . uniqid();
+
+					JVB()->error()->log(
+						'[Queue]:add',
+						'Duplicate ID for non-mergeable operation, generated new ID',
+						[
+							'type' => $type,
+							'existing_state' => $existingById->state,
+						],
+						'warning'
+					);
+				}
+			}
 
 			if ($mergeable) {
 				$existing = $this->storage->findMergeable($type, $userId);
diff --git a/inc/managers/queue/Storage.php b/inc/managers/queue/Storage.php
index 2aa9ab6..0075d3a 100644
--- a/inc/managers/queue/Storage.php
+++ b/inc/managers/queue/Storage.php
@@ -4,15 +4,14 @@
 	exit;
 }
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 
 class Storage
 {
 	private \wpdb $wpdb;
 	private string $table;
-	private CacheManager $cache;
+	private Cache $cache;
 
-	private const CACHE_USER_PREFIX = 'user_queue_';
 	private const CACHE_QUEUE_INFO = 'queue_info';
 
 	public function __construct()
@@ -20,7 +19,7 @@
 		global $wpdb;
 		$this->wpdb = $wpdb;
 		$this->table = $wpdb->prefix . BASE . '_operation_queue';
-		$this->cache = CacheManager::for('queue', DAY_IN_SECONDS);
+		$this->cache = Cache::for('queue', DAY_IN_SECONDS);
 	}
 
 	public function hasProcessingOperations(): bool
@@ -360,13 +359,11 @@
 
 	public function invalidateQueueCache(): void
 	{
-		$this->cache->delete(self::CACHE_QUEUE_INFO);
-		$this->cache->touch();
+		$this->cache->forget(self::CACHE_QUEUE_INFO);
 	}
 
 	private function invalidateUser(int $userId): void
 	{
-		CacheManager::invalidateAll("user_{$userId}");
-		$this->cache->delete(self::CACHE_QUEUE_INFO);
+		$this->cache->forget($userId);
 	}
 }
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
index cf3936d..dd4c80c 100644
--- a/inc/managers/queue/executors/ContentExecutor.php
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -1,7 +1,6 @@
 <?php
 namespace JVBase\managers\queue\executors;
 
-use JVBase\managers\CacheManager;
 use JVBase\managers\queue\{Executor, Operation, Progress, Result};
 use JVBase\meta\MetaManager;
 use JVBase\utility\Features;
@@ -104,7 +103,11 @@
 					}
 
 					$this->savePostFields($newId, $postData);
-					$results[$id] = ['success' => true, 'new_id' => $newId];
+					$results[$id] = [
+						'success' => true,
+						'new_id' => $newId,
+						'processed_fields' => array_keys($postData)
+					];
 
 					if (Features::forContent($content)->has('is_timeline')) {
 						$this->updateTimelineLatestDate($newId);
@@ -117,6 +120,7 @@
 				// Existing post update
 				if (!$this->verifyOwnership((int)$id)) {
 					$progress->failItem($id, 'No permission to modify this post');
+					$errors[$id] = 'No permission';
 					continue;
 				}
 				// Check if this is a timeline post
@@ -156,23 +160,34 @@
 					$this->updateTimelineLatestDate($parentId);
 				}
 
-				$results[$id] = ['success' => true];
+				$results[$id] = [
+					'success' => true,
+					'processed_fields' => array_keys($postData)
+				];
 				$progress->advance(1);
 
-				// Clear caches
-				CacheManager::for($content)->clear();
-				if (jvbSiteUsesFeedBlock()) {
-					CacheManager::for('feed')->clear();
-				}
-
 			} catch (Exception $e) {
 				$progress->failItem($id, $e->getMessage());
 				$errors[$id] = $e->getMessage();
+				$results[$id] = [
+					'success' => false,
+					'error' => $e->getMessage()
+				];
 			}
 		}
 		if (!empty($updateTimelineOrder)) {
-			foreach ($updateTimelineOrder as $parentID) {
-				$this->reorderTimelineByDate($parentID);
+			$processedParents = []; // Track to avoid duplicate processing
+
+			foreach ($updateTimelineOrder as $oldParentID) {
+				if (in_array($oldParentID, $processedParents)) continue;
+
+				$actualParentId = $this->reorderTimelineByDate($oldParentID);
+				$processedParents[] = $actualParentId;
+
+				// If parent changed, mark the new parent as processed too
+				if ($actualParentId !== $oldParentID) {
+					$processedParents[] = $oldParentID;
+				}
 			}
 		}
 
@@ -193,7 +208,12 @@
 
 		return new Result(
 			outcome: $outcome,
-			result: $results
+			result: [
+				'posts' => $results,
+				'errors' => $errors,
+				'updated_count' => count(array_filter($results, fn($r) => $r['success'] ?? false)),
+				'failed_count' => count($errors)
+			]
 		);
 	}
 
@@ -247,10 +267,10 @@
 		}
 	}
 
-	private function reorderTimelineByDate(int $parentId): void
+	private function reorderTimelineByDate(int $parentId): int
 	{
 		$parent = get_post($parentId);
-		if (!$parent) return;
+		if (!$parent) return $parentId;
 
 		// Get all posts in this timeline (parent + children)
 		$children = get_posts([
@@ -269,6 +289,7 @@
 		});
 
 		$newParent = $allPosts[0];
+		$actualParentId = $newParent->ID; // Track the actual parent
 
 		// If parent changed, restructure
 		if ($newParent->ID !== $parentId) {
@@ -320,13 +341,16 @@
 
 			$timelineTerm = $this->calculateTimelineTerm($previousPost, $post);
 			if ($timelineTerm) {
-				$this->getorCreateTerm($post->ID, $timelineTerm, 'timeline');
+				$this->getOrCreateTerm($post->ID, $timelineTerm, 'timeline');
 			}
 
 			$previousPost = $post;
 		}
 
-		$this->updateTimelineLatestDate($newParent->ID);
+		// Update latest_date AFTER reordering with the actual parent
+		$this->updateTimelineLatestDate($actualParentId);
+
+		return $actualParentId; // Return the actual parent ID
 	}
 
 	private function updateTimelineLatestDate(int $parentId): void
@@ -345,6 +369,9 @@
 			'fields' => 'ids'
 		]);
 
+		// Count: parent + children
+		$number = count($children) + 1;
+
 		$allPostIds = array_merge([$parentId], $children);
 
 		// Get all timestamps
@@ -355,7 +382,8 @@
 
 		$latestTimestamp = max($timestamps);
 
-		// Store as UNIX timestamp
+		// Update both meta fields
+		update_post_meta($parentId, BASE . 'number', $number);
 		update_post_meta($parentId, BASE . 'latest_date', $latestTimestamp);
 	}
 
@@ -462,10 +490,6 @@
 			$results = $this->createFromDirect($operation, $data, $images, $progress);
 		}
 
-		// Clear caches
-		CacheManager::for($data['content'])->clear();
-		CacheManager::for('feed')->clear();
-
 		return new Result(
 			outcome: !empty($results) ? 'success' : 'failed',
 			result: $results
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
index c71e680..7461cfd 100644
--- a/inc/managers/queue/executors/UploadExecutor.php
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -385,6 +385,7 @@
 
 		$defaultTitle = 'New '.$config['singular']. ' ';
 		foreach($data['posts'] as $index => $post) {
+			$progress->advance();
 			$title = array_key_exists('post_title', $post['fields'])
 				? sanitize_text_field($post['fields']['post_title'])
 				: $defaultTitle . ($index + 1);
@@ -405,6 +406,7 @@
 			$parent = wp_insert_post($args);
 			$progress->advance();
 			if ($parent && !is_wp_error($parent)) {
+
 				$childPosts = [];
 				$featured = $post['fields']['featured']??null;
 				$featuredID = null;
@@ -442,6 +444,8 @@
 						}
 					}
 				}
+
+				$this->updateTimelineMetadata($parent);
 			}
 		}
 		return new Result(
@@ -450,6 +454,30 @@
 		);
 	}
 
+	/**
+	 * Update timeline parent post with count and latest date
+	 * @param int $parentId Parent timeline post ID
+	 */
+	private function updateTimelineMetadata(int $parentId): void
+	{
+		// Get all child posts
+		$children = get_children([
+			'post_parent' => $parentId,
+			'post_type' => get_post_type($parentId),
+			'post_status' => ['publish', 'draft'],
+			'orderby' => 'date',
+			'order' => 'DESC',
+			'fields' => 'ids'
+		]);
+
+		// Count includes parent + children
+		$number = count($children) + 1;
+
+		// Update both meta fields
+		update_post_meta($parentId, BASE . 'number', $number);
+		update_post_meta($parentId, BASE . 'latest_date', time());
+	}
+
 	// ─────────────────────────────────────────────────────────────
 	// Helper methods
 	// ─────────────────────────────────────────────────────────────
diff --git a/inc/meta/MetaFormOld.php b/inc/meta/MetaFormOld.php
index 7682a19..22851a5 100644
--- a/inc/meta/MetaFormOld.php
+++ b/inc/meta/MetaFormOld.php
@@ -525,7 +525,7 @@
 					$term = get_term($termId, $taxonomy);
 					if ($term && !is_wp_error($term)) {
 						$processedSelected[$term->term_id] = [
-							'name' => $term->name,
+							'name' => html_entity_decode($term->name),
 							'path' => TaxonomySelector::getTermPath($term)
 						];
 					}
@@ -909,7 +909,7 @@
                                         $term = get_term($item_id, $taxonomy);
                                         if (!is_wp_error($term) && $term) {
                                             $item_type = 'term';
-                                            $item_title = $term->name;
+                                            $item_title = html_entity_decode($term->name);
                                             $item_object = $term->taxonomy;
                                             break;
                                         }
diff --git a/inc/meta/MetaManager.php b/inc/meta/MetaManager.php
index e3f5e1f..62e641d 100644
--- a/inc/meta/MetaManager.php
+++ b/inc/meta/MetaManager.php
@@ -1081,6 +1081,9 @@
 						wp_update_term($this->object_id, $this->data->taxonomy, $setFields);
 						break;
 				}
+			} elseif ($this->object_type === 'post' && !empty($this->object_id)) {
+				//Update the 'post modified' date with meta updates, for filtering
+				wp_update_post(['ID' => $this->object_id]);
 			}
 
 		} catch (Exception $e) {
diff --git a/inc/registry/PostTypeRegistrar.php b/inc/registry/PostTypeRegistrar.php
index 13a75f8..f30eb55 100644
--- a/inc/registry/PostTypeRegistrar.php
+++ b/inc/registry/PostTypeRegistrar.php
@@ -6,7 +6,6 @@
 use JVBase\utility\Features;
 use WP_Post;
 use JVBase\meta\MetaRegistry;
-use JVBase\managers\CacheManager;
 if (!defined('ABSPATH')) {
 	exit;
 }
diff --git a/inc/registry/TaxonomyRegistrar.php b/inc/registry/TaxonomyRegistrar.php
index c1bf6e2..67fed28 100644
--- a/inc/registry/TaxonomyRegistrar.php
+++ b/inc/registry/TaxonomyRegistrar.php
@@ -3,10 +3,6 @@
 
 use JVBase\meta\MetaManager;
 use JVBase\meta\MetaRegistry;
-use JVBase\managers\CacheManager;
-use JVBase\utility\Checker;
-use Exception;
-use WP_Error;
 if (!defined('ABSPATH')) {
 	exit;
 }
@@ -31,7 +27,6 @@
 		if ($this->config['is_content'] ?? false) {
 			$this->setupContentTaxonomyHooks();
 		}
-		$this->registerHooks();
 	}
 
 	public function register(): void
@@ -217,7 +212,7 @@
         // Prepare data for insertion/update
         $data = [
             'term_id' => $term_id,
-            'name' => $term->name,
+            'name' => html_entity_decode($term->name),
             'slug' => $term->slug,
             'updated_at' => current_time('mysql')
         ];
@@ -346,30 +341,4 @@
     {
         return jvbContentTaxonomiesTableFields($this->slug)['fields'] ?? [];
     }
-
-	protected function registerHooks():void
-	{
-		$actions = ['created_term', 'edited_term', 'delete_term'];
-		$taxonomy = $this->taxonomy;
-		foreach ($actions as $action) {
-			add_action($action, function($term_id, $tt_id, $tax) use ($taxonomy, $action) {
-				if ($tax !== $taxonomy) return;
-
-				$term = get_term($term_id, $tax);
-
-				CacheManager::for(jvbNoBase($taxonomy))->invalidate();
-				CacheManager::for(jvbNoBase($taxonomy))->clear();
-
-				// Clear cache for associated content types
-				$checker = Checker::getInstance();
-				$content_types = $checker->getContentForTaxonomy($taxonomy);
-
-				foreach ($content_types as $content_type) {
-					CacheManager::for($content_type)->invalidate();
-				}
-
-				do_action("jvb_taxonomy_cache_invalidated_{$taxonomy}", $term, $action);
-			}, 10, 3);
-		}
-	}
 }
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
index 37db58e..0bf3041 100644
--- a/inc/rest/RestRouteManager.php
+++ b/inc/rest/RestRouteManager.php
@@ -6,7 +6,7 @@
 use JVBase\JVB;
 use JVBase\rest\RateLimiter;
 use JVBase\managers\OperationQueue;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\managers\NotificationManager;
 use JVBase\utility\Features;
 use WP_REST_Request;
@@ -35,7 +35,7 @@
     protected array $callback; //route->callback array
     protected string $operation_type; // from QueueManager.js and OperationQueue.php
     protected OperationQueue $queue;
-    protected CacheManager $cache;
+    protected Cache $cache;
     protected NotificationManager $notifications;
     protected string $cache_name ='';
     protected int $cache_ttl = 3600; //1 hour default
@@ -53,7 +53,7 @@
         $this->base = BASE;
         $this->rate_limiter = new RateLimiter();
         if ($this->cache_name !== '') {
-            $this->cache = CacheManager::for($this->cache_name, $this->cache_ttl);
+            $this->cache = Cache::for($this->cache_name, $this->cache_ttl);
         }
         add_action('rest_api_init', [$this, 'registerRoutes']);
     }
@@ -229,11 +229,11 @@
 	 */
 	protected function checkUser(int $userID): bool
 	{
-		$cache = CacheManager::for('users');
+		$cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user');
 
-		return $cache->remember("user_exists_{$userID}", function() use ($userID) {
+		return $cache->remember($userID, function() use ($userID) {
 			return (bool)get_userdata($userID);
-		}, DAY_IN_SECONDS);
+		});
 	}
 
 	/**
@@ -241,11 +241,11 @@
 	 */
 	protected function checkShop(int $shopID): bool
 	{
-		$cache = CacheManager::for('shop');
+		$cache = Cache::for('checkShop',DAY_IN_SECONDS)->connect('taxonomy');
 
-		return $cache->remember("shop_exists_{$shopID}", function() use ($shopID) {
+		return $cache->remember($shopID, function() use ($shopID) {
 			return (bool)term_exists($shopID, BASE . 'shop');
-		}, DAY_IN_SECONDS);
+		});
 	}
 
 	/**
@@ -264,11 +264,11 @@
 		}
 
 		$taxonomy = jvbCheckBase($taxonomy);
-		$cache = CacheManager::for($taxonomy);
+		$cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy');
 
-		return $cache->remember("term_exists_{$termID}", function() use ($termID, $taxonomy) {
+		return $cache->remember($termID, function() use ($termID, $taxonomy) {
 			return (bool)term_exists($termID, $taxonomy);
-		}, DAY_IN_SECONDS);
+		});
 	}
 
 	/**
@@ -276,63 +276,95 @@
 	 */
 	public function isVerifiedUser(int $user_id): bool
 	{
-		$cache = CacheManager::forUser($user_id);
+		$cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user');
 
-		return $cache->remember('is_verified', function() use ($user_id) {
+		return $cache->remember($user_id, function() use ($user_id) {
 			return user_can($user_id, 'skip_moderation');
-		}, DAY_IN_SECONDS);
+		});
 	}
 
-    protected function applyTaxonomyFilters(array $args, array $data):array
-    {
-        $taxQuery = [];
-		foreach($data['taxonomies']??[] as $taxonomy => $terms) {
-			if (array_key_exists(jvbNoBase($taxonomy), JVB_TAXONOMY)) {
-				$taxQuery[] = [
-					'taxonomy'	=> jvbCheckBase($taxonomy),
-					'terms'		=> array_map(
-						'absint',
-						is_array($terms) ? $terms : explode(',', $terms)
-					)
-				];
-			}
+	protected function applyTaxonomyFilters(array $args, array $data):array
+	{
+		// Handle JSON-encoded taxonomy data
+		if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) {
+			$data['taxonomy'] = json_decode($data['taxonomy'], true);
 		}
 
-        if (!empty($taxQuery)) {
-            $args['tax_query'] = array_merge([
-                'relation'  => (array_key_exists('match', $data)) ? 'AND' : 'OR',
-            ], $taxQuery);
-        }
+		$taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? [];
+		$taxQuery = [];
 
-        $authorQuery = [];
-        foreach (jvbAuthorUsers() as $type) {
-            if (array_key_exists($type, $data)) {
-                $artist_ids = array_map(
-                    'absint',
-                    is_array($data[$type]) ?
-                        $data[$type] :
-                        explode(',', $data[$type])
-                );
-                $authorQuery = array_merge($authorQuery, $artist_ids);
-            }
-        }
-        if (!empty($authorQuery)) {
-            $args['author__in'] = array_unique($authorQuery);
-        }
+		foreach($taxonomies as $taxonomy => $terms) {
+			// Better validation: check if taxonomy actually exists
+			if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+				continue;
+			}
 
-        return $args;
-    }
+			$taxQuery[] = [
+				'taxonomy'	=> jvbCheckBase($taxonomy),
+				'field'     => 'term_id',
+				'terms'		=> array_map(
+					'absint',
+					is_array($terms) ? $terms : explode(',', $terms)
+				),
+				'operator'  => 'IN'
+			];
+		}
+
+		if (!empty($taxQuery)) {
+			// Match 'all' = AND, anything else = OR
+			$relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+
+			$args['tax_query'] = array_merge([
+				'relation'  => $relation,
+			], $taxQuery);
+		}
+
+		// Keep existing author filtering logic
+		$authorQuery = [];
+		foreach (jvbAuthorUsers() as $type) {
+			if (array_key_exists($type, $data)) {
+				$artist_ids = array_map(
+					'absint',
+					is_array($data[$type]) ?
+						$data[$type] :
+						explode(',', $data[$type])
+				);
+				$authorQuery = array_merge($authorQuery, $artist_ids);
+			}
+		}
+		if (!empty($authorQuery)) {
+			$args['author__in'] = array_unique($authorQuery);
+		}
+
+		return $args;
+	}
 
     protected function applyOrderFilters(array $args, array $data):array
     {
+		// Check for custom order first
+		$customArgs = $this->applyCustomOrder($args, $data);
+		if ($customArgs !== null) {
+			$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
+			$customArgs['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
+			return $customArgs;
+		}
+
+		//Handle random
         if (array_key_exists('orderby', $data) && $data['orderby'] === 'random') {
             $current_seed = jvbGetRandomSeed();
             $args['orderby'] = 'RAND(' . $current_seed . ')';
             unset($args['order']);
             return $args;
         }
-        if (in_array($data['orderby'], ['date', 'title', 'alphabetical'])) {
-            $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+
+        if (in_array($data['orderby'], ['date', 'modified', 'title', 'alphabetical'])) {
+			if ($data['orderby'] === 'date' && $this->isTimeline($args, $data)) {
+				$args['meta_key'] = BASE . 'latest_date';
+				$args['orderby'] = 'meta_value_num';
+			} else {
+				$args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+			}
+
         } else {
             switch ($data['orderby']) {
                 case 'popularity':
@@ -343,13 +375,18 @@
                     $args['meta_key'] = BASE.'karma';
                     $args['orderby'] = 'meta_value_num';
                     break;
-                default:
-					if ($this->isTimeline($args, $data)) {
-						$args['meta_key'] = BASE . 'latest_date';
-						$args['orderby'] = 'meta_value_num';
-					}else {
-						$args['orderby'] = 'date';
-					}
+				case 'unpopularity':
+					$args['meta_key'] = BASE.'downvotes';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'favourites':
+					$args['meta_key'] = BASE.'total_favourites';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'date':
+				default:
+					$args['orderby'] = 'date';
+					break;
             }
         }
 		$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
@@ -358,6 +395,85 @@
         return $args;
     }
 
+	/**
+	 * Apply custom order if defined in content/taxonomy/user config
+	 *
+	 * @param array $args WP_Query args
+	 * @param array $data Request data
+	 * @return array|null Modified args if custom order found, null otherwise
+	 */
+	protected function applyCustomOrder(array $args, array $data): ?array
+	{
+		$orderby = $data['orderby'] ?? '';
+
+		// Skip if no orderby or it's a standard order
+		if (empty($orderby) || in_array($orderby, ['date', 'modified', 'title', 'alphabetical', 'random', 'popularity', 'karma', 'unpopularity', 'favourites'])) {
+			return null;
+		}
+
+		// Determine content type
+		$post_type = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
+		$content = jvbNoBase($post_type);
+
+		// Get config for this content type
+		$config = Features::getConfig($content);
+		if (!$config) {
+			return null;
+		}
+
+		// Check if this orderby is a custom order
+		$customOrders = $config['custom_order'] ?? [];
+		if (empty($customOrders) || !isset($customOrders[$orderby])) {
+			return null;
+		}
+
+		// Get field definition
+		$fields = $config['fields'] ?? [];
+		if (!isset($fields[$orderby])) {
+			return null;
+		}
+
+		$field = $fields[$orderby];
+
+		// Set meta_key
+		$args['meta_key'] = BASE . $orderby;
+
+		// Determine orderby and meta_type based on field type
+		$fieldType = $field['type'] ?? 'text';
+		$subtype = $field['subtype'] ?? '';
+
+		switch ($fieldType) {
+			case 'number':
+				$args['orderby'] = 'meta_value_num';
+				break;
+
+			case 'text':
+				$args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value';
+				break;
+
+			case 'date':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'DATE';
+				break;
+
+			case 'datetime':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'DATETIME';
+				break;
+
+			case 'true_false':
+			case 'checkbox':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'BINARY';
+				break;
+
+			default:
+				$args['orderby'] = 'meta_value';
+		}
+
+		return $args;
+	}
+
 	protected function isTimeline($args, $data):bool
 	{
 		$post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
@@ -460,55 +576,52 @@
 	 */
 	protected function checkHeaders(
 		WP_REST_Request $request,
-		string|array $content_types,
-		array $additional_params = []
-	): WP_REST_Response|null {
-
-		// Get latest timestamp for the content type(s)
-		$last_modified = CacheManager::getTimestamp($content_types);
-
-		// Generate ETag from request params + timestamp
-		$etag = $this->generateETag($request->get_params(), $additional_params, $last_modified);
-
-		// Check If-None-Match (ETag) header
-		$if_none_match = $request->get_header('If-None-Match');
-		if ($if_none_match === $etag) {
-			return $this->createNotModifiedResponse($etag, $last_modified);
-		}
-
-		// Check If-Modified-Since header
-		$if_modified_since = $request->get_header('If-Modified-Since');
-		if ($if_modified_since) {
-			$if_modified_timestamp = strtotime($if_modified_since);
-			if ($last_modified <= $if_modified_timestamp) {
-				return $this->createNotModifiedResponse($etag, $last_modified);
-			}
-		}
-
-		// Content has changed - store headers to add to successful response
-		$this->response_headers = $this->buildCacheHeaders($etag, $last_modified);
-
-		return null; // Continue processing
-	}
-
-	/**
-	 * Generate ETag from request parameters and timestamp
-	 *
-	 * @param array $params Request parameters
-	 * @param array $additional Additional parameters for uniqueness
-	 * @param int $timestamp Last modified timestamp
-	 * @return string ETag value with quotes
-	 */
-	private function generateETag(array $params, array $additional, int $timestamp): string
+		int|string|array $key,
+		string|array $group = ''
+	): WP_REST_Response|false
 	{
-		// Combine all data that makes this response unique
-		$etag_data = array_merge(
-			$params,
-			$additional,
-			['t' => $timestamp]
-		);
+		$group = ($group!=='') ? $group : $this->cache_name;
+		$cache = $this->cache_name !== $group ? Cache::for($group) : $this->cache;
+		if (!$cache) {
+			return false;
+		}
+		if (is_array($key)) {
+			$key = $cache->generateKey($key);
+		}
 
-		return '"' . md5(serialize($etag_data)) . '"';
+		// Prefer tag freshness if available
+		$tags = $cache->getTags();
+
+		$lastModified = $tags
+			? $cache->getLastModifiedForTags($tags)
+			: $cache::lastModified($group);
+
+		if (!$lastModified) {
+			return false;
+		}
+
+
+		$etag = '"' . sha1($group . ':' . $key . ':' . $lastModified) . '"';
+
+		// ETag check
+		$ifNoneMatch = $request->get_header('if-none-match');
+		if ($ifNoneMatch && trim($ifNoneMatch) === $etag) {
+			return new WP_REST_Response(null, 304);
+		}
+
+		// Last-Modified check
+		$ifModifiedSince = $request->get_header('if-modified-since');
+		if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
+			return new WP_REST_Response(null, 304);
+		}
+
+		// Store headers for response phase
+		$this->response_headers = [
+			'ETag' => $etag,
+			'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
+		];
+
+		return false;
 	}
 
 	/**
@@ -551,35 +664,16 @@
 	protected function addCacheHeaders(WP_REST_Response $response): WP_REST_Response
 	{
 		if (!empty($this->response_headers)) {
-			$response->set_headers($this->response_headers);
-			$this->response_headers = []; // Clear after use
+			foreach ($this->response_headers as $name => $value) {
+				$response->header($name, $value);
+			}
+			$this->response_headers = [];
 		}
+
 		return $response;
 	}
 
 	/**
-	 * Helper: Check headers for user-specific endpoints
-	 * Automatically includes user_id in ETag
-	 *
-	 * @param WP_REST_Request $request The REST request
-	 * @param int $user_id User ID
-	 * @param string|array $content_types Content type(s)
-	 * @return WP_REST_Response|null
-	 */
-	protected function checkUserHeaders(
-		WP_REST_Request $request,
-		int $user_id,
-		string|array $content_types = 'user'
-	): WP_REST_Response|null {
-
-		// Include user-specific timestamp
-		$types = is_array($content_types) ? $content_types : [$content_types];
-		$types[] = "user_{$user_id}";
-
-		return $this->checkHeaders($request, $types, ['user_id' => $user_id]);
-	}
-
-	/**
 	 * Helper to return error response
 	 */
 	protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response
@@ -967,121 +1061,3 @@
 		return JVB()->connect('cloudflare')->verifyTurnstile($token);
 	}
 }
-//
-//Simple example:
-//public function getTattoos(WP_REST_Request $request): WP_REST_Response
-//{
-//	// Check HTTP cache headers first
-//	$cache_check = $this->checkHeaders($request, 'tattoo');
-//	if ($cache_check) {
-//		return $cache_check; // Returns 304 Not Modified
-//	}
-//
-//	// Get data (use CacheManager for data caching too!)
-//	$filters = $request->get_params();
-//	$cache = CacheManager::for('tattoo');
-//
-//	$tattoos = $cache->remember($filters, function() use ($filters) {
-//		return $this->queryTattoos($filters);
-//	}, 300);
-//
-//	$response = new WP_REST_Response(['items' => $tattoos]);
-//	return $this->addCacheHeaders($response); // Add ETag and Last-Modified
-//}
-//
-//Multiple Content Types:
-//public function getTermsWithContent(WP_REST_Request $request): WP_REST_Response
-//{
-//	$taxonomy = $request->get_param('taxonomy');
-//
-//	// Check both taxonomy and its content types
-//	$cache_check = $this->checkHeaders($request, [$taxonomy, 'tattoo', 'artwork']);
-//	if ($cache_check) {
-//		return $cache_check;
-//	}
-//
-//	// ... fetch data ...
-//
-//	$response = new WP_REST_Response($data);
-//	return $this->addCacheHeaders($response);
-//}
-//
-//User-specific:
-//public function getUserFavorites(WP_REST_Request $request): WP_REST_Response
-//{
-//	$user_id = $request->get_param('user');
-//
-//	// Automatically checks user_{$user_id} timestamp + includes user_id in ETag
-//	$cache_check = $this->checkUserHeaders($request, $user_id);
-//	if ($cache_check) {
-//		return $cache_check;
-//	}
-//
-//	// Get user's favorites (cached per user)
-//	$favorites = CacheManager::forUser($user_id)->remember('favorites', function() use ($user_id) {
-//		return $this->getUserFavorites($user_id);
-//	}, 1800);
-//
-//	$response = new WP_REST_Response(['items' => $favorites]);
-//	return $this->addCacheHeaders($response);
-//}
-//
-//Complex with additional params:
-//public function getFilteredContent(WP_REST_Request $request): WP_REST_Response
-//{
-//	$user_id = get_current_user_id();
-//	$filters = $request->get_params();
-//
-//	// Include custom params in ETag for uniqueness
-//	$cache_check = $this->checkHeaders(
-//		$request,
-//		'tattoo',
-//		[
-//			'user_id' => $user_id,
-//			'is_verified' => $this->isVerifiedUser($user_id)
-//		]
-//	);
-//
-//	if ($cache_check) {
-//		return $cache_check;
-//	}
-//
-//	// ... fetch filtered data ...
-//
-//	$response = new WP_REST_Response($data);
-//	return $this->addCacheHeaders($response);
-//}
-
-
-
-/**
- * Use operation lock in your methods like this:
- *
- * public function updateContent(WP_REST_Request $request): WP_REST_Response
- * {
- *     $user_id = get_current_user_id();
- *     $content_id = $request->get_param('content_id');
- *
- *     // Prevent concurrent updates
- *     $lock_key = "update_{$user_id}_{$content_id}";
- *     if (!$this->acquireOperationLock($lock_key)) {
- *         return $this->error(
- *             'An update is already in progress. Please wait.',
- *             'concurrent_operation',
- *             409
- *         );
- *     }
- *
- *     try {
- *         // Do your operation
- *         $result = $this->doUpdate($content_id);
- *
- *         $this->releaseOperationLock($lock_key);
- *         return $this->success($result);
- *
- *     } catch (\Exception $e) {
- *         $this->releaseOperationLock($lock_key);
- *         return $this->error($e->getMessage(), 'operation_failed');
- *     }
- * }
- */
diff --git a/inc/rest/routes/AdminRoutes.php b/inc/rest/routes/AdminRoutes.php
index a5593cb..a32211c 100644
--- a/inc/rest/routes/AdminRoutes.php
+++ b/inc/rest/routes/AdminRoutes.php
@@ -670,7 +670,7 @@
                     break;
             }
             if ($hierarchical && $key === 'term_name') {
-                $item[$key.'_path'] = JVB()->routes('term')->getTermPath($ID, $term->name, $term->taxonomy);
+                $item[$key.'_path'] = JVB()->routes('term')->getTermPath($ID, html_entity_decode($term->name), $term->taxonomy);
             }
         }
 
diff --git a/inc/rest/routes/ApprovalRoutes.php b/inc/rest/routes/ApprovalRoutes.php
index f795dac..62fc5e4 100644
--- a/inc/rest/routes/ApprovalRoutes.php
+++ b/inc/rest/routes/ApprovalRoutes.php
@@ -4,7 +4,7 @@
 
 use JVBase\JVB;
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\utility\Features;
 use WP_User;
 use WP_REST_Request;
@@ -946,7 +946,7 @@
         }
 
         // Clear caches
-        CacheManager::invalidateGroup('approvals');
+		$this->cache->flush();
     }
 
     public function getApprovals(WP_REST_Request $request)
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index 3fc50bb..46c7d45 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -6,7 +6,7 @@
 use JVBase\managers\queue\executors\ContentExecutor;
 use JVBase\managers\queue\TypeConfig;
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 use JVBase\utility\Features;
 use WP_Post;
@@ -37,7 +37,7 @@
 		$this->cache_name = 'user_content_' . get_current_user_id();
 		parent::__construct();
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 
 		$this->action = 'dash-';
@@ -166,7 +166,6 @@
 	public function handleContentUpdate(WP_REST_Request $request): WP_REST_Response
 	{
 		$data = $request->get_params();
-		error_log('Received data: ' . print_r($data, true));
 		$user_id = $data['user'];
 
 		if (!$this->userCheck($user_id)) {
@@ -257,6 +256,7 @@
 	{
 		$params = $request->get_params();
 		$user_id = $params['user'];
+
 		if (!$this->userCheck($user_id)) {
 			return new WP_REST_Response([
 				'success' => false,
@@ -316,12 +316,10 @@
 			$args['s'] = sanitize_text_field($params['search']);
 		}
 
+
 		$key = $this->cache->generateKey($args);
 		// Check HTTP cache headers with the specific content type
-		$content_type = $params['content'] ?? $params['type'];
-		$cache_check = $this->checkHeaders($request, $content_type, [
-			'filter_hash' => $key,
-		]);
+		$cache_check = $this->checkHeaders($request, $key);
 		if ($cache_check) {
 			return $cache_check;
 		}
@@ -347,7 +345,6 @@
 		$this->taxonomies = $this->getTaxonomies($this->post_type);
 		$posts = array_map([$this, 'prepareItem'], $query->posts);
 
-
 		$data = [
 			'items' => $posts,
 			'total' => $query->found_posts,
@@ -580,11 +577,6 @@
 				];
 
 			}
-
-			CacheManager::for($post_data['content'])->clear();
-			if (jvbSiteUsesFeedBlock()) {
-				CacheManager::for('feed')->clear();
-			}
 		}
 
 		if (jvbSiteHasNotifications()) {
@@ -786,20 +778,20 @@
 				$meta = new MetaManager($timeline['id'], 'post');
 				$oldValues = $meta->getAll(array_keys($allowedFields));
 
-				// Set number taxonomy to menu_order (always update for reordering)
-				if (!$is_parent) {
-					$number_value = $order;
-					$term = get_term_by('name', (string)$number_value, BASE . 'number');
-					if (!$term) {
-						$result = wp_insert_term((string)$number_value, BASE . 'number');
-						if ($result && !is_wp_error($result)) {
-							$term = $result['term_id'];
-						}
-					} else {
-						$term = $term->term_id;
-					}
-					$allowedFields['number'] = $term;
-				}
+//				// Set number taxonomy to menu_order (always update for reordering)
+//				if (!$is_parent) {
+//					$number_value = $order;
+//					$term = get_term_by('name', (string)$number_value, BASE . 'number');
+//					if (!$term) {
+//						$result = wp_insert_term((string)$number_value, BASE . 'number');
+//						if ($result && !is_wp_error($result)) {
+//							$term = $result['term_id'];
+//						}
+//					} else {
+//						$term = $term->term_id;
+//					}
+//					$allowedFields['number'] = $term;
+//				}
 
 				// Auto-timeline logic
 				if ($prevDate) {
@@ -828,7 +820,7 @@
 				$updateValues = array_filter($allowedFields, function ($value, $key) use ($oldValues) {
 					return (!array_key_exists($key, $oldValues) || $value !== $oldValues[$key]);
 				}, ARRAY_FILTER_USE_BOTH);
-				error_log('Setting values for ' . $timeline['id'] . ': ' . print_r($updateValues, true));
+
 
 				$meta->setAll($updateValues);
 				$timeline['id'] = (int)$timeline['id'];
@@ -845,8 +837,8 @@
 		}
 
 		if ($clearParent) {
-			$this->cache->clear();
-			CacheManager::onPostSave($parent_id, $parent_post);
+			$this->cache->flush();
+			Cache::onPostChange($parent_id, $parent_post);
 		}
 
 
@@ -1029,7 +1021,7 @@
 		}
 		$item['fields']['timeline'] = $subFields;
 		$item['images'] = $item['images'] + $images;
-
+		$item['number'] = $mainMeta->getValue('number');
 
 		return $item;
 	}
@@ -1179,10 +1171,6 @@
 //                            $queue->updateOperationProgress($operation->id, $key + 1, $total);
 						}
 					}
-
-					//Clear cache
-					CacheManager::for($data['content'])->clear();
-					CacheManager::for('feed')->clear();
 				}
 
 				return [
@@ -1203,4 +1191,121 @@
 
 		return $result;
 	}
+	// Add to ContentRoutes.php
+
+	/**
+	 * One-time migration: Set latest_date meta for all timeline posts
+	 * Call this once via WP-CLI or a temporary admin page
+	 *
+	 * Usage: add_action('admin_init', function() {
+	 *     if (current_user_can('manage_options')) {
+	 *         JVB()->routes('content')->migrateTimelineLatestDates();
+	 *     }
+	 * });
+	 */
+	public function migrateTimelineLatestDates(): array
+	{
+		global $wpdb;
+
+		$results = [
+			'processed' => 0,
+			'updated' => 0,
+			'skipped' => 0,
+			'errors' => []
+		];
+
+		// Get all timeline post types
+		$timeline_types = [];
+		foreach (JVB_CONTENT as $type => $config) {
+			if (Features::forContent($type)->has('is_timeline')) {
+				$timeline_types[] = BASE . $type;
+			}
+		}
+
+		if (empty($timeline_types)) {
+			return $results;
+		}
+
+		// Get all parent timeline posts
+		$args = [
+			'post_type' => $timeline_types,
+			'post_status' => ['publish', 'draft'],
+			'post_parent' => 0,
+			'posts_per_page' => -1,
+			'fields' => 'ids'
+		];
+
+		$parent_ids = get_posts($args);
+
+		foreach ($parent_ids as $parent_id) {
+			$results['processed']++;
+
+			try {
+				// Get all children including the parent
+				$children = get_children([
+					'post_parent' => $parent_id,
+					'post_status' => ['publish', 'draft'],
+					'orderby' => 'menu_order',
+					'order' => 'ASC',
+					'fields' => 'ids'
+				]);
+
+				// Add parent to the list
+				array_unshift($children, $parent_id);
+
+				// Find latest date among all posts
+				$latest_timestamp = 0;
+
+				foreach ($children as $post_id) {
+					$date = get_post_meta($post_id, BASE . 'date', true);
+
+					if ($date) {
+						$timestamp = strtotime($date);
+						if ($timestamp > $latest_timestamp) {
+							$latest_timestamp = $timestamp;
+						}
+					}
+				}
+
+				// Update parent with latest date
+				if ($latest_timestamp > 0) {
+					update_post_meta($parent_id, BASE . 'latest_date', $latest_timestamp);
+					$results['updated']++;
+					error_log("Updated post {$parent_id} with latest_date: {$latest_timestamp}");
+				} else {
+					// Fallback to parent post's post_date
+					$parent_post = get_post($parent_id);
+					$fallback_timestamp = strtotime($parent_post->post_date);
+
+					if ($fallback_timestamp > 0) {
+						update_post_meta($parent_id, BASE . 'latest_date', $fallback_timestamp);
+						$results['updated']++;
+						error_log("Updated post {$parent_id} with fallback latest_date: {$fallback_timestamp} (from post_date)");
+					} else {
+						$results['skipped']++;
+						error_log("No dates found for post {$parent_id}");
+					}
+				}
+
+			} catch (Exception $e) {
+				$results['errors'][] = [
+					'post_id' => $parent_id,
+					'error' => $e->getMessage()
+				];
+			}
+		}
+
+		error_log('Timeline migration complete: ' . print_r($results, true));
+		return $results;
+	}
 }
+
+
+//add_action('init', function() {
+////	delete_option('jvb_timeline_migrated');
+//	if (get_option('jvb_timeline_migrated')) {
+//		return;
+//	}
+//	JVB()->routes('content')->migrateTimelineLatestDates();
+//	update_option('jvb_timeline_migrated', true);
+//});
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index a5de911..19121b2 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -2,8 +2,8 @@
 namespace JVBase\rest\routes;
 
 use JVBase\JVB;
+use JVBase\managers\Cache;
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
 use WP_REST_Request;
 use WP_REST_Response;
 use WP_Error;
@@ -16,16 +16,21 @@
 class FavouritesRoutes extends RestRouteManager
 {
     protected array $valid_types;
-    protected int $user_id;
+	protected Cache $listsCache;
+	protected Cache $sharedListsCache;
+	protected Cache $favouritesCache;
 
     public function __construct()
     {
         $this->cache_name = 'favourites';
         parent::__construct();
+		$this->cache->connect('post')->connect('user')->connect('taxonomy');
+		$this->listsCache = Cache::for('lists')->connect('favourites', true);
+		$this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true);
+		$this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true);
 
         $this->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY));
 
-        $this->user_id = get_current_user_id();
         $this->action = 'favourites-';
 
 
@@ -118,13 +123,14 @@
     {
 		$args = $this->buildParams($request);
 		if (!$args['user'] || $args['user'] === ''){
-			$result = [
+			return $this->addCacheHeaders(new WP_REST_Response([
 				'success'	=> false,
 				'message'	=> 'No user set'
-			];
+			]));
 		}
+		$key = $this->cache->generateKey($args);
 		// Check HTTP cache headers for user-specific data
-		$cache_check = $this->checkUserHeaders($request, $args['user'], 'favourites');
+		$cache_check = $this->checkHeaders($request, $key);
 		if ($cache_check) {
 			return $cache_check;
 		}
@@ -133,10 +139,9 @@
             $result = $this->getAllFavourites($args['user']);
 		} else {
 			$result = $this->cache->remember(
-				$args,
+				$this->cache->generateKey($args),
 				function() use ($args) {
-					$response = new WP_REST_Response($this->getFilteredFavourites($args));
-					return $this->addCacheHeaders($response);
+					return $this->getFilteredFavourites($args);
 				}
 			);
 		}
@@ -237,7 +242,7 @@
         }
 
 		$result = $this->cache->remember(
-			'user_'.$user_id.'_all_favourites',
+			$user_id,
 			function() use ($user_id) {
 				return $this->fetchAllFavourites($user_id);
 			}
@@ -398,8 +403,15 @@
 			]);
 		}
 
+		$params = [
+			'user'	=> $user_id,
+		];
+		if ($request->get_param('id')) {
+			$params['list'] = sanitize_text_field($request->get_param('id'));
+		}
+		$key = $this->listsCache->generateKey($params);
 		// Check HTTP cache headers
-		$cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_lists');
+		$cache_check = $this->checkHeaders($request, $key);
 		if ($cache_check) {
 			return $cache_check;
 		}
@@ -427,28 +439,21 @@
         if (!$this->checkUser($user_id)) {
             return [];
         }
-        $key = sprintf(
-            'user_%d_lists',
-            $user_id
-        );
-        if ($include_shared) {
-            $key = $key.'_shared';
-        }
-        $cache = $this->cache->get($key, 'favourites_lists');
-        if ($cache) {
-            return $cache;
-        }
 
-        global $wpdb;
-        error_log('Attempting to get available lists..');
-        $lists_table = $wpdb->prefix . BASE . 'favourites_lists';
-        $items_table = $wpdb->prefix . BASE . 'favourites_list_items';
-        $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
+		$cache = ($include_shared) ? $this->sharedListsCache : $this->listsCache;
+		return $cache->remember(
+			$user_id,
+			function() use ($user_id, $include_shared) {
+				global $wpdb;
+				error_log('Attempting to get available lists..');
+				$lists_table = $wpdb->prefix . BASE . 'favourites_lists';
+				$items_table = $wpdb->prefix . BASE . 'favourites_list_items';
+				$shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
 
-        try {
-            // Get owned lists
-            $owned_query = $wpdb->prepare(
-                "SELECT l.*,
+				try {
+					// Get owned lists
+					$owned_query = $wpdb->prepare(
+						"SELECT l.*,
                 COUNT(DISTINCT i.id) as item_count,
                 TRUE as is_owner,
                 FALSE as is_shared
@@ -457,16 +462,16 @@
             WHERE l.user_id = %d
             GROUP BY l.id
             ORDER BY l.created_at DESC",
-                $user_id
-            );
+						$user_id
+					);
 
-            $lists = $wpdb->get_results($owned_query);
-            error_log('Lists result: '.print_r($lists, true));
+					$lists = $wpdb->get_results($owned_query);
+					error_log('Lists result: '.print_r($lists, true));
 
-            // Add shared lists if requested
-            if ($include_shared) {
-                $shared_query = $wpdb->prepare(
-                    "SELECT l.*,
+					// Add shared lists if requested
+					if ($include_shared) {
+						$shared_query = $wpdb->prepare(
+							"SELECT l.*,
                     u.display_name as owner_name,
                     COUNT(DISTINCT i.id) as item_count,
                     s.permission_type,
@@ -479,35 +484,33 @@
                 WHERE s.user_id = %d
                 GROUP BY l.id
                 ORDER BY l.created_at DESC",
-                    $user_id
-                );
+							$user_id
+						);
 
-                $shared_lists = $wpdb->get_results($shared_query);
-                error_log('Shared lists: '.print_r($shared_lists, true));
-                $lists = [
-                    'owned' => $lists,
-                    'shared'=> $shared_lists,
-                ];
-            }
+						$shared_lists = $wpdb->get_results($shared_query);
+						error_log('Shared lists: '.print_r($shared_lists, true));
+						$lists = [
+							'owned' => $lists,
+							'shared'=> $shared_lists,
+						];
+					}
+					error_log('Lists: '.print_r($lists, true));
+					return [
+						'success' => true,
+						'lists' => $lists
+					];
+				} catch (Exception $e) {
+					JVB()->error()->log(
+						'favourites',
+						'Error getting available lists: ' . $e->getMessage(),
+						['user_id' => $user_id],
+						'error'
+					);
 
-            // Cache result
-            $this->cache->set($key, ['success' => true, 'lists'=>$lists], 'favourites_lists');
-
-            error_log('Lists: '.print_r($lists, true));
-            return [
-                'success' => true,
-                'lists' => $lists
-            ];
-        } catch (Exception $e) {
-            JVB()->error()->log(
-                'favourites',
-                'Error getting available lists: ' . $e->getMessage(),
-                ['user_id' => $user_id],
-                'error'
-            );
-
-            return [];
-        }
+					return [];
+				}
+			}
+		);
     }
 
     /**
@@ -524,7 +527,7 @@
             $user_id,
             $list_id
         );
-        $cache = $this->cache->get($key, 'favourites_lists');
+        $cache = $this->listsCache->get($key);
         if ($cache) {
             return new WP_REST_Response($cache);
         }
@@ -649,7 +652,7 @@
             ]
         ];
 
-        $this->cache->set($key, $response_data, 'favourites_lists');
+        $this->listsCache->set($key, $response_data);
         return new WP_REST_Response($response_data);
     }
 
@@ -830,27 +833,30 @@
 			]);
 		}
 
+		$list_id = $request->get_param('list_id');
+
+		if (!$list_id) {
+			return $this->createErrorResponse(
+				self::ERROR_MISSING_PARAMS,
+				'List ID is required',
+				400
+			);
+		}
+
+		$args = [
+			'user'	=> $user_id,
+			'list'	=> sanitize_text_field($list_id),
+		];
+		$key = $this->sharedListsCache->generateKey($args);
 		// Check HTTP cache headers
-		$cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_shares');
+		$cache_check = $this->checkHeaders($request, $key);
 		if ($cache_check) {
 			return $cache_check;
 		}
-		$list_id = $request->get_param('list_id');
 
-        if (!$list_id) {
-            return $this->createErrorResponse(
-                self::ERROR_MISSING_PARAMS,
-                'List ID is required',
-                400
-            );
-        }
 
-        $key = sprintf(
-            'user_%d_shares_for_list_%d',
-            $user_id,
-            $list_id
-        );
-        $cache = $this->cache->get($key, 'favourites_list_shares');
+
+        $cache = $this->sharedListsCache->get($key);
         if ($cache) {
             return new WP_REST_Response($cache);
         }
@@ -925,7 +931,7 @@
             ];
 
             // Cache the results
-            $this->cache->set($key, $response_data, 'favourites_list_shares');
+            $this->sharedListsCache->set($key, $response_data);
 
 			$response = new WP_REST_Response($response_data);
 			return $this->addCacheHeaders($response);
@@ -1265,7 +1271,7 @@
                 'target_id' => $term_id,
                 'date_added' => $item->date_added ?? current_time('mysql'),
                 'notes' => $item->notes ?? '',
-                'title' => $term->name,
+                'title' => html_entity_decode($term->name),
                 'url' => get_term_link($term)
             ];
 
@@ -1442,8 +1448,8 @@
             }
             error_log('Results: '.print_r($results, true));
 
-            $this->cache->invalidate('favourite_counts_by_type_' . $user_id.'_all');
-            $this->cache->invalidate('favourite_counts_by_type_' . $user_id.'_not_all');
+            $this->cache->forget('favourite_counts_by_type_' . $user_id.'_all');
+            $this->cache->forget('favourite_counts_by_type_' . $user_id.'_not_all');
             return [
                 'success' => true,
                 'result' => $results
@@ -1637,8 +1643,10 @@
             $this->removeRelatedNotifications($user_id, $type, $target_id);
 
             // Invalidate cache
-            CacheManager::invalidateGroup($this->cache_name);
-            CacheManager::invalidateGroup('favourites_lists');
+			$this->cache->flush();
+			$this->listsCache->flush();
+			$this->sharedListsCache->flush();
+			$this->favouritesCache->flush();
 
             return [
                 'success' => true,
@@ -1722,9 +1730,9 @@
                 }
 
                 // Invalidate notification cache for this user
-                if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
-                    JVB()->notification()->clearNotificationCache($owner_id);
-                }
+//                if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
+//                    JVB()->notification()->clearNotificationCache($owner_id);
+//                }
             }
         } catch (Exception $e) {
             // Log but continue
@@ -2011,7 +2019,7 @@
             $wpdb->query('COMMIT');
 
             // Invalidate relevant caches
-            CacheManager::invalidateGroup('favourites_lists');
+			$this->listsCache->flush();
 
             return [
                 'success' => true,
@@ -3302,47 +3310,47 @@
             switch ($operation->type) {
                 case 'favourites_batch':
                     $response = $this->processBatches($user_id, $data);
-                    CacheManager::invalidateGroup($this->cache_name);
+					$this->cache->flush();
                     return $response;
 
                 case 'favourite_notes':
                     $response =  $this->processNote($user_id, $data);
-                    CacheManager::invalidateGroup($this->cache_name);
+					$this->cache->flush();
                     return $response;
 
                 case 'favourite_list_create':
                     $response = $this->processListCreate($user_id, $data);
-                    CacheManager::invalidateGroup('favourites_lists');
+					$this->listsCache->flush();
                     return $response;
 
                 case 'favourite_list_update':
                     $response = $this->processUpdateList($user_id, $data);
-                    CacheManager::invalidateGroup('favourites_lists');
+					$this->listsCache->flush();
                     return $response;
 
                 case 'favourite_list_delete':
                     $response = $this->processListDeletion($user_id, $data);
-                    CacheManager::invalidateGroup('favourites_lists');
+                    $this->listsCache->flush();
                     return $response;
 
                 case 'favourite_list_add':
                     $response = $this->processAddToList($user_id, $data);
-                    CacheManager::invalidateGroup('favourites_lists');
+                    $this->listsCache->flush();
                     return $response;
 
                 case 'favourite_list_remove':
                     $response = $this->removeFromList($user_id, $data);
-                    CacheManager::invalidateGroup('favourites_lists');
+                    $this->listsCache->flush();
                     return $response;
 
                 case 'favourite_list_share':
                     $response = $this->shareList($user_id, $data);
-                    CacheManager::invalidateGroup('favourites_lists_shares');
+					$this->sharedListsCache->flush();
                     return $response;
 
                 case 'favourite_list_unshare':
                     $response = $this->unshareList($user_id, $data);
-                    CacheManager::invalidateGroup('favourites_lists_shares');
+					$this->sharedListsCache->flush();
                     return $response;
 
                 default:
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index 9988cf2..2c83a63 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\rest\routes;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\rest\RestRouteManager;
 use JVBase\integrations\Umami;
 use JVBase\meta\MetaManager;
@@ -32,8 +32,13 @@
 		$this->cache_name = 'feed';
 		$this->cache_ttl = 86400;
 		parent::__construct();
+		$this->cache
+			->connect('post')
+			->connect('taxonomy')
+			->connect('user');
+
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 
 	}
@@ -45,28 +50,6 @@
 		if (Features::hasIntegration('umami')) {
 			$this->tracker = JVB()->connect('umami');
 		}
-		$this->setupCacheConnections();
-	}
-
-	/**
-	 * Set up cache connections for automatic invalidation
-	 */
-	protected function setupCacheConnections(): void
-	{
-		// Connect to all content types with show_feed
-		$contentTypes = Features::getTypesWithFeature('show_feed', 'content');
-		foreach ($contentTypes as $type) {
-			CacheManager::for('feed_item_'.$type)->connectTo('post');
-			$this->cache->connectTo('post', $type);
-		}
-
-		// Connect to all taxonomies with show_feed
-		$taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
-		foreach ($taxonomies as $tax) {
-			CacheManager::for('feed_item_'.$tax)->connectTo('taxonomy');
-			$this->cache->connectTo('taxonomy', $tax);
-		}
-
 	}
 
 	/**
@@ -101,21 +84,20 @@
 				$post = get_post($postID);
 				$type = jvbNoBase($post->post_type);
 				$metaType = 'post';
-				$cache = CacheManager::for('feed_item_'.$type);
 				break;
 			default:
 				$post = get_term($postID, jvbCheckBase($type));
 				$type = jvbNoBase($type);
 				$metaType = 'term';
-				$cache = CacheManager::for('feed_item_'.$type);
 				break;
 		}
 		if (!$post || is_wp_error($post)) {
 			return [];
 		}
-//
-//		return $cache->remember($postID,
-//			function() use ($postID, $type, $metaType, $post, $skip) {
+
+		return $this->cache->remember(
+			$postID,
+			function() use ($postID, $type, $metaType, $post, $skip) {
 				$config = null;
 				switch ($metaType) {
 					case 'post':
@@ -184,17 +166,19 @@
 							$out['user_id'] = $owner;
 						}
 						$out['url'] = get_term_link($postID, $type);
+						$out['title'] = html_entity_decode($post->name);
 						break;
 					case 'post':
 						$out['date'] = $post->post_date;
+						$out['modified'] = $post->post_modified;
 						$out['user_id'] = (int)$post->post_author;
 						$out['url'] = get_the_permalink($postID);
+						$out['title']= get_the_title($postID);
 						break;
 				}
-//				return $out;
-//			}
-//		);
-		return $out;
+				return $out;
+			}
+		);
 	}
 
 
@@ -251,13 +235,14 @@
 			$item['taxonomies'] = array_merge($item['taxonomies'], $this->extractTaxonomies($f, $postID, jvbNoBase($post->post_type)));
 			$images[$f['post_thumbnail']] = jvbImageData((int) $f['post_thumbnail']);
 		}
-		$item['fields']['number'] = count($children);
+		$item['number'] = (int)get_post_meta($post->ID,BASE.'number', true);
 		$item['fields']['before'] = get_post_thumbnail_id($children[0]);
 		$item['fields']['after'] = get_post_thumbnail_id($children[array_key_last($children)]);
 
 		$item['fields']['timeline'] = $subFields;
 		$item['images'] = $item['images'] + $images;
 
+
 		return $item;
 	}
 	protected function extractTaxonomies(array $fields, int $postID, string $content):array {
@@ -292,13 +277,12 @@
 
 	protected function formatTaxonomy(WP_Term|int $term, int $postID, string $type)
 	{
-		$cache = CacheManager::for(jvbNoBase($term->taxonomy));
-		return $cache->remember(
-			'feed_link_'.$term->term_id,
+		return $this->cache->remember(
+			$term->term_id,
 			function () use ($term, $postID, $type) {
 				$base = [
 					'ID' => $term->term_id,
-					'title' => htmlspecialchars_decode($term->name),
+					'title' => html_entity_decode($term->name),
 					'url' => get_term_link($term->term_id, $term->taxonomy),
 				];
 				if ($this->tracker) {
@@ -313,18 +297,26 @@
 
 	protected function getAuthorData(WP_Post $post)
 	{
-		$author = $this->cache->get($post->post_author, 'author_data');
-		if (!$author) {
-			$author = [
-				'id' => $post->post_author,
-				'label' => 'Artist',
-				'value' => get_the_author_meta('display_name', $post->post_author),
-				'icon' => 'artist',
-				'url' => get_the_permalink(get_user_meta($post->post_author, BASE . 'link', true)),
-			];
-			$this->cache->set($post->post_author, $author, 'author_data');
-		}
-		return $author;
+		$author = $post->post_author;
+		$userLink = get_user_meta($author, BASE.'link', true);
+		return $this->cache->remember(
+			$userLink,
+			function () use ($userLink, $author) {
+				$label = jvbUserRole($author);
+				if (array_key_exists($label, JVB_USER)) {
+					$label = JVB_USER[$label]['label'];
+				} else {
+					$label = 'Artist';
+				}
+				return [
+					'id'	=> $userLink,
+					'label'	=> $label,
+					'value'	=> get_the_title($userLink),
+					'icon'	=> 'user',
+					'url'	=> get_the_permalink($userLink),
+				];
+			}
+		);
 	}
 
 	protected function getTaxonomies(int $postID, string $content): array
@@ -340,16 +332,23 @@
 					'icon' => $config,
 					'title' => JVB_TAXONOMY[$config]['plural'],
 					'terms' => array_map(function ($term) use ($tax, $postID, $content) {
-						return [
-							'ID' => $term->term_id,
-							'title' => htmlspecialchars_decode($term->name),
-							'url' => get_term_link($term->term_id, $tax),
-							'umami_click' => $this->tracker->trackTaxonomyClick($term->term_id, $tax, [
-								'from' => $content . '_' . $postID
-							])
-						];
+						$item = $this->cache->remember(
+							$term->term_id,
+							function() use ($term, $tax, $content, $postID) {
+								return [
+									'ID'	=> $term->term_id,
+									'title'	=> html_entity_decode($term->name),
+									'url'	=> get_term_link($term->term_id, $tax),
+								];
+							}
+						);
+						$item['umami_click'] = $this->tracker->trackTaxonomyClick($term->term_id, $tax, [
+							'from'	=> $content.'_'.$postID
+						]);
+						return $item;
 					}, $terms),
 				];
+
 			}
 		}
 		return $out;
@@ -386,43 +385,43 @@
 		return $this->applyFavouritesFilter($args, $data);
 	}
 
-	protected function applyTaxonomyFilters(array $args, array $data): array
-	{
-		if (!isset($data['taxonomy']) || empty($data['taxonomy'])) {
-			return $args;
-		}
-
-		$taxonomyFilters = $data['taxonomy'];
-
-		// Validate taxonomies exist and sanitize
-		$validFilters = [];
-		foreach ($taxonomyFilters as $taxonomy => $terms) {
-			if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
-				continue;
-			}
-
-			$validFilters[] = [
-				'taxonomy' => jvbCheckBase($taxonomy),
-				'field' => 'term_id',
-				'terms' => array_map('absint', (array)$terms),
-				'operator' => 'IN'
-			];
-		}
-
-		if (empty($validFilters)) {
-			return $args;
-		}
-
-		// Determine relation based on match filter
-		$relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
-
-		$args['tax_query'] = array_merge(
-			['relation' => $relation],
-			$validFilters
-		);
-
-		return $args;
-	}
+//	protected function applyTaxonomyFilters(array $args, array $data): array
+//	{
+//		if (!array_key_exists('taxonomy', $data) || empty($data['taxonomy'])) {
+//			return $args;
+//		}
+//
+//		$taxonomyFilters = $data['taxonomy'];
+//
+//		// Validate taxonomies exist and sanitize
+//		$validFilters = [];
+//		foreach ($taxonomyFilters as $taxonomy => $terms) {
+//			if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+//				continue;
+//			}
+//
+//			$validFilters[] = [
+//				'taxonomy' => jvbCheckBase($taxonomy),
+//				'field' => 'term_id',
+//				'terms' => array_map('absint', (array)$terms),
+//				'operator' => 'IN'
+//			];
+//		}
+//
+//		if (empty($validFilters)) {
+//			return $args;
+//		}
+//
+//		// Determine relation based on match filter
+//		$relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+//
+//		$args['tax_query'] = array_merge(
+//			['relation' => $relation],
+//			$validFilters
+//		);
+//
+//		return $args;
+//	}
 
 	/**
 	 * @param WP_REST_Request $request
@@ -432,19 +431,17 @@
 	public function handleFeedRequest(WP_REST_Request $request): WP_REST_Response
 	{
 		$args = $this->buildRequestArgs($request);
-		$cacheContext = $this->buildCacheContext($args, $request);
+		$key = $this->cache->generateKey($args);
 
 		// Check HTTP cache headers first
 		$cache_check = $this->checkHeaders(
 			$request,
-			$cacheContext['content_types'],
-			$cacheContext['additional_params']
+			$key
 		);
 		if ($cache_check) {
 			return $cache_check; // Returns 304 Not Modified
 		}
 
-		$key = $this->cache->generateKey($args);
 		$cached = $this->cache->get($key);
 		if ($cached) {
 			if ($request->get_param('highlight')) {
@@ -458,7 +455,7 @@
 		// Fetch and format items
 		$items = $this->fetchFeedItems($args);
 
-		$ttl = (str_contains($args['orderby'], 'RAND')) ? 1800 : $this->cache_ttl;
+		$ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cache_ttl;
 		$this->cache->set($key, $items, $ttl);
 
 		if ($request->get_param('highlight')) {
@@ -1262,6 +1259,7 @@
 				// Get content types with show_feed
 				$contentTypes = Features::getTypesWithFeature('show_feed', 'content');
 				foreach ($contentTypes as $slug) {
+					$this->cache->tag('content:'.$slug);
 					$contentConfig = JVB_CONTENT[$slug] ?? null;
 					if (!$contentConfig) continue;
 
@@ -1282,6 +1280,8 @@
 						continue;
 					}
 
+					$this->cache->tag('taxonomy:'.$slug);
+
 					$config[$slug] = [
 						'type' => 'taxonomy',
 						'singular' => $taxConfig['singular'] ?? ucfirst($slug),
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index c5c0d36..c2cd228 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -2,7 +2,7 @@
 namespace JVBase\rest\routes;
 
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 use JVBase\managers\CloudflareTurnstile;
 use JVBase\blocks\FormBlock;
@@ -23,7 +23,7 @@
  */
 class FormRoutes extends RestRouteManager
 {
-	protected CacheManager $cache;
+	protected Cache $cache;
 	protected FormBlock $form_block;
 	protected CloudflareTurnstile|null $turnstile;
 
@@ -31,7 +31,7 @@
 	{
 		parent::__construct();
 		$this->action = 'form-';
-		$this->cache = CacheManager::for('forms', HOUR_IN_SECONDS);
+		$this->cache = Cache::for('forms', HOUR_IN_SECONDS);
 
 
 		// Add query vars
diff --git a/inc/rest/routes/Invitations.php b/inc/rest/routes/Invitations.php
index 5c7638a..6fc4a2a 100644
--- a/inc/rest/routes/Invitations.php
+++ b/inc/rest/routes/Invitations.php
@@ -748,7 +748,7 @@
 					$toContentTax[] = sprintf(
 						"<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>",
 						$inviter_name,
-						$term->name,
+						html_entity_decode($term->name),
 						$taxonomy
 					);
 				}
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 8c5d9d0..e775960 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -649,7 +649,7 @@
 			}
 
 			// Lockout expired - clear attempts
-			$this->cache->delete($cache_key);
+			$this->cache->forget($cache_key);
 			return true;
 		}
 
@@ -689,7 +689,7 @@
 	protected function clearFailedAttempts(string $username): void
 	{
 		$cache_key = 'login_attempts_' . md5($username);
-		$this->cache->delete($cache_key);
+		$this->cache->forget($cache_key);
 	}
 
 
diff --git a/inc/rest/routes/NewsRoutes.php b/inc/rest/routes/NewsRoutes.php
index 0811cec..57f875a 100644
--- a/inc/rest/routes/NewsRoutes.php
+++ b/inc/rest/routes/NewsRoutes.php
@@ -1,9 +1,7 @@
 <?php
 namespace JVBase\rest\routes;
 
-use JVBase\JVB;
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
 use JVBase\meta\MetaManager;
 use JVBase\managers\NewsRelationships;
 use WP_Query;
@@ -376,7 +374,7 @@
             }
         }
 
-        CacheManager::invalidateGroup($this->cache_name);
+        $this->cache->flush();
 
         return [
 			'success'	=> true,
diff --git a/inc/rest/routes/NotificationsRoutes.php b/inc/rest/routes/NotificationsRoutes.php
index eb39e8b..0ac3889 100644
--- a/inc/rest/routes/NotificationsRoutes.php
+++ b/inc/rest/routes/NotificationsRoutes.php
@@ -386,22 +386,24 @@
 			]);
 		}
 
+
+		$params = $this->getSanitizedData($user_id, $data);
+		$params['user'] = $user_id;
+		$key = $this->cache->generateKey($params);
 		// Check HTTP cache headers (includes notification types in timestamp check)
-		$cache_check = $this->checkUserHeaders($request, $user_id, 'notifications');
+		$cache_check = $this->checkHeaders($request, $key);
 		if ($cache_check) {
 			return $cache_check;
 		}
 
         // Step 1: Build status/order/filter params
-        $params = $this->getSanitizedData($user_id, $data);
         $status = $params['status'];
         $limit = $params['limit'];
         $offset = $params['page'];
         $type = $params['type'];
 
         // Try cache first with validated parameters
-        $cache_key = "user_{$user_id}_merged_notifications_{$status}_{$type}_{$limit}_{$offset}";
-        $cached = $this->cache->get($cache_key);
+        $cached = $this->cache->get($key);
         if ($cached) {
             $response = new WP_REST_Response($cached);
 			return $this->addCacheHeaders($response);
@@ -447,7 +449,7 @@
             ];
 
             // Cache the result
-            $this->cache->set($cache_key, $response, 'notifications_' . $user_id);
+            $this->cache->set($key, $response);
             $response = new WP_REST_Response($response);
 			return $this->addCacheHeaders($response);
         } catch (Exception $e) {
diff --git a/inc/rest/routes/OptionsRoutes.php b/inc/rest/routes/OptionsRoutes.php
index ebf18d6..a4a1ead 100644
--- a/inc/rest/routes/OptionsRoutes.php
+++ b/inc/rest/routes/OptionsRoutes.php
@@ -3,7 +3,7 @@
 
 use JVBase\JVB;
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 use JVBase\meta\MetaSanitizer;
 use WP_REST_Request;
@@ -109,8 +109,8 @@
 
 		do_action('jvbOptionsRoute', $data);
 
-		$cache = CacheManager::for('options', 1800);
-		$cache->invalidate();
+		$cache = Cache::for('options', 1800);
+		$cache->flush();
 		return [
 			'success'	=> true,
 			'result'	=> $results
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index b26bc70..db567e3 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -3,7 +3,7 @@
 
 use Exception;
 use JVBase\JVB;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\rest\RestRouteManager;
 use WP_REST_Request;
 use WP_REST_Response;
@@ -23,7 +23,7 @@
         parent::__construct();
 
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
     }
 
@@ -94,7 +94,8 @@
 		$ids = $request->get_param('ids');
 		$limit = intval($request->get_param('limit'));
 		// Use base class user-specific header checking
-		$cache_check = $this->checkUserHeaders($request, $user_id, 'queue');
+		$key = $this->cache->generateKey(['user'=> $user_id, 'status'=> $status, 'ids'=> $ids, 'limit'=> $limit]);
+		$cache_check = $this->checkHeaders($request, $key);
 		if ($cache_check) {
 			return $cache_check;
 		}
@@ -285,7 +286,7 @@
 		$result = $this->processQueueAction($action, $operations, $user_id);
 
 		if ($result['success']) {
-			CacheManager::updateTimestamp("user_{$user_id}");
+			Cache::touch($user_id);
 		}
 
 		return new WP_REST_Response($result);
diff --git a/inc/rest/routes/ReferralRoutes.php b/inc/rest/routes/ReferralRoutes.php
index fd0627b..6f920c8 100644
--- a/inc/rest/routes/ReferralRoutes.php
+++ b/inc/rest/routes/ReferralRoutes.php
@@ -312,7 +312,7 @@
 			}
 		}
 
-		$this->cache->clear();
+		$this->cache->flush();
 
 		return $this->success(['message' => "Referral marked as {$status}"]);
 	}
@@ -348,7 +348,7 @@
 		}
 
 		$this->wpdb->delete($this->referrals_table, ['id' => $referral_id], ['%d']);
-		$this->cache->clear();
+		$this->cache->flush();
 
 		return $this->success(['message' => 'Referral removed']);
 	}
@@ -508,7 +508,7 @@
 		];
 
 		update_option(BASE . 'referral_settings', $settings);
-		$this->cache->clear();
+		$this->cache->flush();
 
 		return $this->success([
 			'message' => 'Settings updated',
@@ -638,7 +638,7 @@
 			$data['message']
 		);
 		if ($result['success']) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 
 		// Build summary message
@@ -735,7 +735,7 @@
 		}
 
 		// Clear cache
-		$this->cache->clear();
+		$this->cache->flush();
 
 		return new WP_REST_Response([
 			'success' => true,
@@ -805,7 +805,7 @@
 		}
 
 		// Clear cache
-		$this->cache->clear();
+		$this->cache->flush();
 
 		return new WP_REST_Response([
 			'success' => true,
diff --git a/inc/rest/routes/ResponseRoutes.php b/inc/rest/routes/ResponseRoutes.php
index 0c430a7..31cbd9d 100644
--- a/inc/rest/routes/ResponseRoutes.php
+++ b/inc/rest/routes/ResponseRoutes.php
@@ -3,7 +3,7 @@
 
 use JVBase\JVB;
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use WP_REST_Request;
 use WP_REST_Response;
 use WP_Error;
@@ -245,9 +245,8 @@
         }
         error_log('Sanitized action. Here we go!');
 
-        $this->queue = JVB()->queue();
-        // Add to queue
-        $operation = $this->queue->queueOperation(
+      	// Add to queue
+        $operation = JVB()->queue()->queueOperation(
             $action.'_response',
             $user_id,
             $queue_data,
@@ -271,7 +270,6 @@
      */
     public function updateResponse(WP_REST_Request $request):WP_REST_Response
     {
-        $this->queue = JVB()->queue();
         $id = (int) $request->get_param('id');
         $data = $request->get_params();
         $user_id = (int) $data['user'] ?? get_current_user_id();
@@ -308,7 +306,7 @@
         ];
 
         // Add to queue
-        $operation = $this->queue->queueOperation(
+        $operation = JVB()->queue()->queueOperation(
             'update_response',
             $user_id,
             $queue_data,
@@ -329,7 +327,6 @@
      */
     public function deleteResponse(WP_REST_Request $request):WP_REST_Response
     {
-        $this->queue = JVB()->queue();
         $id = (int) $request->get_param('id');
         $user_id = get_current_user_id();
         $operation_id = $request->get_param('id') ?? uniqid('response_delete_');
@@ -355,7 +352,7 @@
         }
 
         // Add to queue
-        $operation = $this->queue->queueOperation(
+        $operation = JVB()->queue()->queueOperation(
             'delete_response',
             $user_id,
             ['response_id' => $id],
@@ -457,8 +454,7 @@
                     }
                 }
 
-                $this->clearItemCache($data['item_id'], $data['content']);
-                CacheManager::invalidateGroup($this->cache_name);
+                $this->cache->forget($data['item_id']);
                 return ['success' => true, 'result' => $response_id];
 
             case 'update_response':
@@ -502,8 +498,8 @@
 					];
                 }
 
-                $this->clearItemCache($data['item_id'], $data['content']);
-                CacheManager::invalidateGroup($this->cache_name);
+                $this->cache->forget($data['item_id']);
+                $this->cache->flush();
                 return ['success' => true, 'result' => $updated];
 
             case 'delete_response':
@@ -536,7 +532,7 @@
                         ['%s', '%s', '%s'],
                         ['%d']
                     );
-                    CacheManager::invalidateGroup($this->cache_name);
+                    $this->cache->flush();
                     return ['success' => true, 'result' => $updated ];
                 } else {
                     // No replies, safe to actually delete
@@ -559,8 +555,8 @@
 						];
                     }
 
-                    $this->clearItemCache($data['item_id'], $data['content']);
-                    CacheManager::invalidateGroup($this->cache_name);
+                    $this->cache->forget($data['item_id']);
+                    $this->cache->flush();
                     return ['success' => true, 'result' => $deleted];
                 }
         }
diff --git a/inc/rest/routes/SEORoutes.php b/inc/rest/routes/SEORoutes.php
index f75dc1a..e1da79c 100644
--- a/inc/rest/routes/SEORoutes.php
+++ b/inc/rest/routes/SEORoutes.php
@@ -2,7 +2,7 @@
 namespace JVBase\rest\routes;
 
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\managers\SEO\ConfigManager;
 use JVBase\managers\SEO\SchemaBuilder;
 use WP_REST_Request;
@@ -138,7 +138,7 @@
 		}
 
 		// Invalidate cache
-		$this->cache->invalidate();
+		$this->cache->flush();
 
 		return new WP_REST_Response([
 			'success' => true,
@@ -187,7 +187,7 @@
 		}
 
 		// Invalidate cache
-		$this->cache->invalidate();
+		$this->cache->flush();
 
 		return new WP_REST_Response([
 			'success' => true,
@@ -224,7 +224,7 @@
 		}
 
 		// Invalidate cache
-		$this->cache->invalidate();
+		$this->cache->flush();
 
 		return new WP_REST_Response([
 			'success' => true,
diff --git a/inc/rest/routes/SettingsRoutes.php b/inc/rest/routes/SettingsRoutes.php
index 811984a..ff4faaa 100644
--- a/inc/rest/routes/SettingsRoutes.php
+++ b/inc/rest/routes/SettingsRoutes.php
@@ -3,7 +3,7 @@
 
 use JVBase\JVB;
 use JVBase\rest\RestRouteManager;
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\meta\MetaManager;
 use JVBase\meta\MetaSanitizer;
 use WP_REST_Request;
@@ -60,8 +60,6 @@
             ]);
         }
 
-		$this->queue = JVB()->queue();
-
 
         $fields = JVB()->getFields('user');
         $meta = new MetaSanitizer();
@@ -81,7 +79,7 @@
                 //Sanitize values
                 $data[$name] = $meta->sanitize($value, $fields[$name]);
                 if ($name === 'notify') {
-                    CacheManager::for('usernames')->delete($user_id);
+                    Cache::for('usernames')->forget($user_id);
                 }
             }
         }
@@ -89,7 +87,7 @@
             $data['notification_preferences'] = $add;
         }
 
-        $this->queue->queueOperation(
+        JVB()->queue()->queueOperation(
             'user_settings',
             $user_id,
             $data,
@@ -149,7 +147,7 @@
 				}
 
             }
-            CacheManager::for($this->cache_name)->invalidate();
+            $this->cache->flush();
         }
         return [
             'success'   => true,
@@ -218,7 +216,7 @@
             // Success - commit transaction
             $wpdb->query('COMMIT');
 
-            CacheManager::for($this->cache_name)->invalidate();
+            $this->cache->flush();
             return [
                 'success' => true,
                 'result' => 'Notification preferences updated successfully'
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index 5dc3b87..438139e 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -23,7 +23,7 @@
         $this->cache_name = 'terms';
         parent::__construct();
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
         $this->per_page = 20;
 
@@ -112,7 +112,7 @@
 
         $data = [
             'id' => $term->term_id,
-            'name' => $term->name,
+            'name' => html_entity_decode($term->name),
             'slug' => $term->slug,
             'taxonomy' => $term->taxonomy,
         ];
@@ -134,7 +134,7 @@
                         $term = get_term($rel->related_term_id, $rel->related_taxonomy);
                         return [
                             'id' => $rel->related_term_id,
-                            'name' => $term ? $term->name : 'Unknown',
+                            'name' => $term ? html_entity_decode($term->name) : 'Unknown',
                             'count' => $rel->relationship_count
                         ];
                     }, $relationships);
@@ -498,7 +498,7 @@
 		return $this->cache->remember($cache_key, function() use ($term, $taxonomy) {
 			$data = [
 				'id' => $term->term_id,
-				'name' => $term->name,
+				'name' => html_entity_decode($term->name),
 				'slug' => $term->slug,
 				'parent' => $term->parent,
 				'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy),
@@ -912,7 +912,7 @@
                     'message' => 'Term already exists',
                     'term' => [
                         'id' => $term->term_id,
-                        'name' => $term->name,
+                        'name' => html_entity_decode($term->name),
                         'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy)
                     ]
                 ]);
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 1da0412..5735208 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -1139,6 +1139,9 @@
 			$files = $request->get_file_params();
 			$args = $this->buildUploadArgs($request);
 
+			error_log('handleGroupingRequest Files: '.print_r($files, true));
+			error_log('handleGroupingRequest args: '.print_r($args, true));
+
 			if (!$args['content'] || !$args['user'] || !$args['posts']) {
 				$this->logError('Missing required data');
 				return new WP_REST_Response([
diff --git a/inc/users/UserSettings.php b/inc/users/UserSettings.php
index 1a12bac..1ee08e9 100644
--- a/inc/users/UserSettings.php
+++ b/inc/users/UserSettings.php
@@ -313,7 +313,7 @@
                     break;
                 case 'term':
                     $term = get_term($result->target_id, BASE.$type);
-                    $name = $term ? $term->name : '';
+                    $name = $term ? html_entity_decode($term->name) : '';
                     break;
                 default:
                     $name = '';
diff --git a/inc/utility/Image.php b/inc/utility/Image.php
index 4901435..df5c2f3 100644
--- a/inc/utility/Image.php
+++ b/inc/utility/Image.php
@@ -1,7 +1,7 @@
 <?php
 namespace JVBase\utility;
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 
 if (!defined('ABSPATH')) {
 	exit;
@@ -12,20 +12,22 @@
  */
 class Image
 {
-	protected ?CacheManager $cache = null;
+	protected ?Cache $cache = null;
+	protected ?Cache $imgData = null;
 
 	public function __construct()
 	{
-		$this->cache = CacheManager::for('images')->connectTo('post', 'attachment');
+		$this->cache = Cache::for('images')->connect('post');
+		$this->imgData = Cache::for('imageData')->connect('post');
 		if (JVB_TESTING) {
-			$this->cache->clear();
+			$this->cache->flush();
 		}
 	}
 
 	public function formatImage(int $ID, string $start = 'tiny', string $replace = 'large', bool $addLink = true, ?string $postSlug = null):string
 	{
 		$return = $this->cache->remember(
-			['ID' => $ID, 'start' => $start, 'replace' => $replace],
+			$this->cache->generateKey(['ID' => $ID, 'start' => $start, 'replace' => $replace]),
 			function() use ($ID, $start, $replace) {
 				// Define size order for progressive enhancement
 				$sizeOrder = ['tiny', 'directory-preview', 'thumbnail', 'medium', 'large', 'full'];
@@ -121,4 +123,25 @@
 		}
 		return '';
 	}
+
+	public function getImageData(int $imgID):array
+	{
+		return $this->imgData->remember(
+			$imgID,
+			function() use ($imgID) {
+				if (!wp_get_attachment_image($imgID, 'tiny')) {
+					return [];
+				}
+				return [
+					'tiny' 			=> wp_get_attachment_image_src($imgID, 'tiny')[0],
+					'small' 		=> wp_get_attachment_image_src($imgID, 'medium')[0],
+					'medium' 		=> wp_get_attachment_image_src($imgID, 'large')[0],
+					'large' 		=> wp_get_attachment_image_src($imgID, 'full')[0],
+					'image-alt-text'=> get_post_meta($imgID, '_wp_attachment_image_alt', true),
+					'image-title'	=> get_the_title($imgID),
+					'image-caption' => get_the_excerpt($imgID),
+				];
+			}
+		);
+	}
 }
diff --git a/jvb.php b/jvb.php
index 67b2174..70a9342 100644
--- a/jvb.php
+++ b/jvb.php
@@ -84,6 +84,11 @@
 add_filter('show_admin_bar', '__return_false');
 
 define('JVB_TESTING', str_contains(get_home_url(),'.test'));
+//if (JVB_TESTING) {
+//	error_log('In testing mode...');
+//} else {
+//	error_log('Not in testing mode...');
+//}
 
 const JVB_DIR = WP_PLUGIN_DIR . '/jvb';
 define('JVB_URL', plugin_dir_url(__FILE__));
@@ -134,7 +139,6 @@
 require(JVB_DIR . '/inc/meta/_setup.php');
 require(JVB_DIR . '/inc/importers/_setup.php');
 require(JVB_DIR . '/inc/managers/_setup.php');
-
 /**
  * Get an icon element
  *
diff --git a/src/drawer-menu/render.php b/src/drawer-menu/render.php
index d0826a3..05da41a 100644
--- a/src/drawer-menu/render.php
+++ b/src/drawer-menu/render.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 use JVBase\ui\Navigation;
 
 $menu_id = $attributes['menuId'] ?? '';
@@ -13,8 +13,7 @@
 	return '<p>Please configure the drawer menu in block settings.</p>';
 }
 
-$cache = CacheManager::for('drawer');
-$cache->clear();
+$cache = Cache::for('drawer');
 
 if (!is_front_page()) {
 	$menu_items[] = [
diff --git a/src/feed/style.scss b/src/feed/style.scss
index 583aa82..de52421 100644
--- a/src/feed/style.scss
+++ b/src/feed/style.scss
@@ -823,13 +823,7 @@
 
 
 .item-grid:has([data-timeline]) {
-	grid-template-columns: repeat(1, 1fr);
-}
-
-@media (min-width:768px) {
-	.item-grid:has([data-timeline]) {
-		grid-template-columns: repeat(2, 1fr);
-	}
+	grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
 }
 
 .items-wrap [type=radio],
diff --git a/src/feed/view.js b/src/feed/view.js
index 103423b..1248322 100644
--- a/src/feed/view.js
+++ b/src/feed/view.js
@@ -77,13 +77,17 @@
 		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)
@@ -179,6 +183,12 @@
 			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() {
@@ -433,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',
 			{
@@ -443,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
@@ -452,12 +469,13 @@
 				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;
@@ -471,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);
 		}
@@ -503,6 +521,10 @@
 	}
 
 	createItemElement(item) {
+		if (typeof item !== 'object') {
+			item = this.store.get(item);
+			if (!item) return;
+		}
 		return this.templates.create(`feedItem${window.uppercaseFirst(item.content)}`, item);
 	}
 	splitIDs(value) {
@@ -616,7 +638,7 @@
 		];
 
 		if (afterEl) {
-			afterEl.textContent = `After ${item.fields.number - 1} Tx`;
+			afterEl.textContent = `After ${item.number} Tx`;
 		}
 		if (number) {
 			number.textContent = item.fields.number;
diff --git a/src/list/render.php b/src/list/render.php
index f8ef568..489c379 100644
--- a/src/list/render.php
+++ b/src/list/render.php
@@ -60,7 +60,7 @@
                             if ($terms && !is_wp_error($terms)) {
                                 $term = $terms[0];
                                 $extra[] = [
-                                    'name'  => $term->name,
+                                    'name'  => html_entity_decode($term->name),
                                     'url'   => get_term_link($term->term_id, $item),
                                     'id'    => $term->term_id,
                                     'type'  => $item,
@@ -90,7 +90,7 @@
                     $extra = false;
                     $list = jvbAlphabetizeMe(
                         $list,
-                        $term->name,
+						html_entity_decode($term->name),
                         get_term_link($term->term_id, $selected_type['slug']),
                         $term->term_id,
                         $extra
diff --git a/src/summary/render.php b/src/summary/render.php
index 4c22c7a..ca5cde1 100644
--- a/src/summary/render.php
+++ b/src/summary/render.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 
 if (!defined('ABSPATH')) {
     exit; // Exit if accessed directly
@@ -31,10 +31,9 @@
 function jvbRenderArtistSummary():string
 {
     $current = get_queried_object();
-    $cache = CacheManager::for('artists', WEEK_IN_SECONDS);
-    $key = 'artist-bio-'.$current->ID;
+    $cache = Cache::for('artistSummary', WEEK_IN_SECONDS);
+    $key = $current->ID;
     $cached = $cache->get($key);
-    $cached = false;
     if ($cached !== false) {
         return $cached;
     }
@@ -97,8 +96,8 @@
                             $link = get_term_link((int)$style, BASE.'style');
                             ?>
                             <li>
-                                <a href="<?=$link?>" title="Learn more about <?=$term->name?>">
-                                    <?=strtolower($term->name)?>
+                                <a href="<?=$link?>" title="Learn more about <?=html_entity_decode($term->name)?>">
+                                    <?=strtolower(html_entity_decode($term->name))?>
                                 </a>
                             </li>
                             <?php
@@ -158,8 +157,8 @@
 {
     $current = get_queried_object();
 
-    $cache = CacheManager::for('shops', WEEK_IN_SECONDS);
-    $key = 'shop-bio-'.$current->term_id;
+    $cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy');
+    $key = $current->term_id;
     $cached = $cache->get($key);
     $cached = false;
     if ($cached !== false) {
@@ -167,8 +166,6 @@
     }
 
     ob_start();
-    $handler = JVB()->getContent('shop');
-
 
     $meta = new JVBase\meta\MetaManager($current->term_id, 'term');
     $rating = $meta->getValue('average_rating');
@@ -281,7 +278,7 @@
 
     <?php
     $finished = ob_get_clean();
-//    $cache->set($key, $finished);
+    $cache->set($key, $finished);
     return $finished;
 }
 
@@ -289,10 +286,9 @@
 function jvbRenderTermSummary()
 {
     $current = get_queried_object();
-    $cache = CacheManager::for(jvbNoBase($current->taxonomy), WEEK_IN_SECONDS);
+    $cache = Cache::for('term_summary', WEEK_IN_SECONDS)->connect('taxonomy');
     $key = $current->ID;
     $cached = $cache->get($key);
-    $cached = false;
     if ($cached !== false) {
         return $cached;
     }
diff --git a/templates/dashboard/sections/news.php b/templates/dashboard/sections/news.php
index ae4d46f..242f4c5 100644
--- a/templates/dashboard/sections/news.php
+++ b/templates/dashboard/sections/news.php
@@ -1,6 +1,6 @@
 <?php
 
-use JVBase\managers\CacheManager;
+use JVBase\managers\Cache;
 
 if (!defined('ABSPATH')) {
     exit; // Exit if accessed directly
@@ -9,7 +9,7 @@
     wp_redirect(get_home_url(null, '/dash'));
     exit;
 }
-$cache = CacheManager::for('news', 3600);
+$cache = Cache::for('news', 3600);
 $check = $cache->get('type-options');
 
 if ($check) {
@@ -24,7 +24,7 @@
         foreach ($terms as $term) {
             $typeOptions[] = [
                 'id'    => $term->term_id,
-                'name'    => $term->name,
+                'name'    => html_entity_decode($term->name),
                 'count'    => $term->count,
             ];
         }

--
Gitblit v1.10.0