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

---
 inc/managers/UserTermsManager.php                |    2 
 inc/managers/CustomTable.php                     |   13 
 inc/managers/Cache.php                           |   34 +
 jvb.php                                          |    9 
 inc/rest/routes/FavouritesRoutes.php             |    8 
 inc/blocks/FAQBlock.php                          |   55 +
 inc/managers/OperationQueue.php                  |    2 
 inc/managers/ReferralManager.php                 |    6 
 inc/managers/DashboardManager.php                |   17 
 inc/managers/queue/Storage.php                   |    2 
 inc/rest/routes/ContentRoutes.php                |    2 
 assets/js/min/referral.min.js                    |    2 
 build/feed/block.json                            |    2 
 assets/js/min/queue.min.js                       |    2 
 inc/rest/routes/FeedRoutes.php                   |   80 +--
 inc/registrar/config/seo/Meta.php                |    1 
 assets/js/concise/AuthManager.js                 |  117 -----
 assets/js/min/auth.min.js                        |    2 
 inc/registrar/Fields.php                         |    5 
 inc/managers/FavouritesManager.php               |    2 
 inc/registrar/config/seo/Schema.php              |   42 --
 inc/rest/routes/QueueRoutes.php                  |    2 
 inc/managers/LoginManager.php                    |    7 
 inc/managers/queue/executors/UploadExecutor.php  |   10 
 inc/blocks/TimelineBlock.php                     |    8 
 inc/managers/queue/executors/ContentExecutor.php |   20 
 inc/managers/ScriptLoader.php                    |    6 
 inc/utility/Image.php                            |    2 
 assets/js/concise/Queue.js                       |    5 
 inc/managers/Notifications/EmailDigests.php      |    2 
 build/feed/view.asset.php                        |    2 
 assets/js/concise/DataStore.js                   |    2 
 src/feed/style.scss                              |    3 
 build/feed/style-index-rtl.css                   |    2 
 inc/helpers/all.php                              |   14 
 assets/js/concise/Referral.js                    |    4 
 build/feed/view.js                               |    2 
 build/feed/style-index.css                       |    2 
 inc/rest/routes/LoginRoutes.php                  |   37 +
 inc/managers/DirectoryManager.php                |   21 
 inc/managers/InvitationsManager.php              |    2 
 inc/blocks/FeedBlock.php                         |  398 ++++++++++++++++----
 inc/managers/SEO/BreadcrumbManager.php           |    8 
 src/feed/view.js                                 |   45 +-
 inc/managers/KarmaManager.php                    |    2 
 inc/registrar/Registrar.php                      |  115 ++++++
 src/feed/block.json                              |    2 
 47 files changed, 695 insertions(+), 433 deletions(-)

diff --git a/assets/js/concise/AuthManager.js b/assets/js/concise/AuthManager.js
index 760934f..ab70c92 100644
--- a/assets/js/concise/AuthManager.js
+++ b/assets/js/concise/AuthManager.js
@@ -17,8 +17,6 @@
 		this.nonces = {};
 
 		this.subscribers = new Set();
-		this.storageKey = `${jvbBase.base}auth_state`;
-		this.cacheMetaKey = `${jvbBase.base}auth_meta`;
 		this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
 
 		this.init();
@@ -28,26 +26,23 @@
 	 * Initialize authentication
 	 */
 	async init() {
-		if (this.isAuthenticating) {
-			return this.ready();
-		}
-
+		if (this.isAuthenticating) return;
 		this.isAuthenticating = true;
 
 		try {
-			const cached = this.getCachedAuth();
-			if (cached) {
-				this.setAuthData(cached);
+			// Inlined by wp_localize_script — zero cost
+			if (typeof jvbAuth !== 'undefined') {
+				this.setAuthData(jvbAuth);
 				this.initialized = true;
 				this.isAuthenticating = false;
-				this.notify('auth-loaded', { fromCache: true });
+				this.notify('auth-loaded', { fromCache: false });
 				return;
 			}
 
+			// Fallback: REST fetch (Redis-backed, fast server-side)
 			await this.fetchAuth();
 
 		} catch (error) {
-			console.error('Failed to initialize auth:', error);
 			this.clearAuthData();
 			this.initialized = true;
 			this.isAuthenticating = false;
@@ -110,31 +105,16 @@
 		const response = await fetch(`${jvbSettings.api}auth/status`, {
 			method: 'GET',
 			credentials: 'same-origin',
-			headers: {
-				'Content-Type': 'application/json'
-			}
+			headers: { 'Content-Type': 'application/json' }
 		});
 
-		if (!response.ok) {
-			throw new Error('Auth check failed');
-		}
+		if (!response.ok) throw new Error('Auth check failed');
 
 		const authData = await response.json();
-
-		// Check if session changed (e.g., logout in another tab)
-		const cachedMeta = sessionStorage.getItem(this.cacheMetaKey);
-		if (cachedMeta) {
-			const meta = JSON.parse(cachedMeta);
-			if (meta.session_id && meta.session_id !== authData.session_id) {
-				this.clearCachedAuth();
-				this.notify('session-changed', {});
-			}
-		}
-
-		this.cacheAuth(authData);
 		this.setAuthData(authData);
 		this.initialized = true;
 		this.isAuthenticating = false;
+
 		this.notify('auth-loaded', { fromCache: false });
 	}
 
@@ -161,62 +141,8 @@
 		this.authenticated = false;
 		this.user = null;
 		this.nonces = {};
-
-		sessionStorage.removeItem(this.storageKey);
-		sessionStorage.removeItem(this.cacheMetaKey );
 	}
 
-	/**
-	 * Get cached auth data (only if cookie matches)
-	 */
-	getCachedAuth() {
-		try {
-			const cachedAuth = sessionStorage.getItem(this.storageKey);
-			const cacheMeta = sessionStorage.getItem(this.cacheMetaKey);
-
-			if (!cachedAuth || !cacheMeta) {
-				return null;
-			}
-
-			const meta = JSON.parse(cacheMeta);
-			const authData = JSON.parse(cachedAuth);
-
-			// Time-based expiry (nonce freshness)
-			if (Date.now() - meta.timestamp > this.cacheExpiry) {
-				this.clearCachedAuth();
-				return null;
-			}
-
-			// Session changed (login/logout in another tab/window)
-			// We'll verify this on next fetch and clear if mismatched
-
-			return authData;
-
-		} catch (error) {
-			console.error('Error reading cached auth:', error);
-			return null;
-		}
-	}
-
-	/**
-	 * Cache auth data in sessionStorage
-	 */
-	cacheAuth(authData) {
-		try {
-			sessionStorage.setItem(this.storageKey, JSON.stringify(authData));
-			sessionStorage.setItem(this.cacheMetaKey, JSON.stringify({
-				session_id: authData.session_id || null,
-				timestamp: Date.now()
-			}));
-		} catch (error) {
-			console.error('Error caching auth:', error);
-		}
-	}
-
-	clearCachedAuth() {
-		sessionStorage.removeItem(this.storageKey);
-		sessionStorage.removeItem(this.cacheMetaKey);
-	}
 
 	/**
 	 * Refresh authentication (force new fetch)
@@ -256,21 +182,13 @@
 	 * Handle successful login (call after login completes)
 	 */
 	async handleLogin(authData = null) {
-		// Clear old cache
-		sessionStorage.removeItem(this.storageKey);
-		sessionStorage.removeItem(this.cacheMetaKey);
-
-		// If auth data provided, use it directly
 		if (authData) {
-			this.cacheAuth(authData);
 			this.setAuthData(authData);
 			this.initialized = true;
 			this.isAuthenticating = false;
 			this.notify('auth-loaded', { fromCache: false, fromLogin: true });
 			return;
 		}
-
-		// Otherwise fetch fresh (for backward compatibility)
 		await this.refresh();
 	}
 
@@ -312,23 +230,6 @@
 		});
 	}
 
-	/**
-	 * Wait for auth to be ready
-	 */
-	ready() {
-		if (this.initialized) {
-			return Promise.resolve();
-		}
-
-		return new Promise(resolve => {
-			const unsubscribe = this.subscribe((event) => {
-				if (event === 'auth-loaded' || event === 'auth-error') {
-					unsubscribe();
-					resolve();
-				}
-			});
-		});
-	}
 }
 
 // Initialize global instance
diff --git a/assets/js/concise/DataStore.js b/assets/js/concise/DataStore.js
index 284c913..539c82b 100644
--- a/assets/js/concise/DataStore.js
+++ b/assets/js/concise/DataStore.js
@@ -48,7 +48,6 @@
 	register(name, configs = [], version = 1.25) {
 		if (!Array.isArray(configs)) configs = [configs];
 		if (configs.length === 0) return;
-
 		if (!this.dbConfig.has(name)) {
 			this.dbConfig.set(name, {
 				dbName: `${jvbBase.base}${name}`,
@@ -127,6 +126,7 @@
 			}
 		});
 
+
 		// Initialize database asynchronously
 		this.initDB(name).catch(error => {
 			console.error(`Failed to initialize store "${name}":`, error);
diff --git a/assets/js/concise/Queue.js b/assets/js/concise/Queue.js
index 1122684..0290aa9 100644
--- a/assets/js/concise/Queue.js
+++ b/assets/js/concise/Queue.js
@@ -31,6 +31,7 @@
 		this.initElements();
 		this.initListeners();
 		this.initStore();
+
 		if (this.canUpdateUI && this.ui.panel) {
 			this.popup = window.jvbPopup.registerPopup({
 				popup: this.ui.panel,
@@ -616,12 +617,12 @@
 					body: requestBody
 				}
 			);
-			console.log('Sending request with data: ', req);
+			// console.log('Sending request with data: ', req);
 			const result = await response.json();
 			if (skip) {
 				operation.data = {};
 			}
-			console.log('Result: ', result);
+			// console.log('Result: ', result);
 			if (response.ok && result.success) {
 				this.notify('sent-to-server', req);
 				if (result.id && operation.id !== result.id) {
diff --git a/assets/js/concise/Referral.js b/assets/js/concise/Referral.js
index e715af0..7764657 100644
--- a/assets/js/concise/Referral.js
+++ b/assets/js/concise/Referral.js
@@ -78,7 +78,7 @@
 					endpoint: 'referrals/stats',
 					TTL: 5 * 60 * 1000,
 					showLoading: false,
-					delayFetch: false,
+					delayFetch: true,
 					filters: {
 						type: 'dashboard',
 						user: window.auth.getUser()
@@ -91,7 +91,7 @@
 					endpoint: 'referrals',
 					TTL: 10 * 60 * 1000,
 					showLoading: false,
-					delayFetch: false,
+					delayFetch: true,
 					filters: {
 						user: window.auth.getUser(),
 						status: 'all',
diff --git a/assets/js/min/auth.min.js b/assets/js/min/auth.min.js
index eb5d36f..08e1ae0 100644
--- a/assets/js/min/auth.min.js
+++ b/assets/js/min/auth.min.js
@@ -1 +1 @@
-window.auth=new class{constructor(){this.initialized=!1,this.isAuthenticating=!1,this.authenticated=!1,this.user=!1,this.nonces={},this.subscribers=new Set,this.storageKey=`${jvbBase.base}auth_state`,this.cacheMetaKey=`${jvbBase.base}auth_meta`,this.cacheExpiry=3e5,this.init()}async init(){if(this.isAuthenticating)return this.ready();this.isAuthenticating=!0;try{const t=this.getCachedAuth();if(t)return this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!0});await this.fetchAuth()}catch(t){console.error("Failed to initialize auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}async refreshNonce(t="wp_rest"){try{return await this.fetchAuth(),this.getNonce(t)}catch(t){return console.error("Failed to refresh nonce:",t),null}}async fetch(t,e={}){const i=async(s=0)=>{const a={...!(e.body instanceof FormData)&&{"Content-Type":"application/json"},...e.headers,"X-WP-Nonce":this.getNonce()},h=await fetch(t,{...e,credentials:"same-origin",headers:a});if((403===h.status||401===h.status)&&0===s){const t=await h.clone().json();if("rest_cookie_invalid_nonce"===t.code||t.message?.includes("Cookie check"))return console.log("Nonce invalid, refreshing auth..."),await this.refresh(),i(s+1)}return h};return i()}async fetchAuth(){const t=await fetch(`${jvbSettings.api}auth/status`,{method:"GET",credentials:"same-origin",headers:{"Content-Type":"application/json"}});if(!t.ok)throw new Error("Auth check failed");const e=await t.json(),i=sessionStorage.getItem(this.cacheMetaKey);if(i){const t=JSON.parse(i);t.session_id&&t.session_id!==e.session_id&&(this.clearCachedAuth(),this.notify("session-changed",{}))}this.cacheAuth(e),this.setAuthData(e),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-loaded",{fromCache:!1})}setAuthData(t){const e=this.initialized&&this.authenticated;this.authenticated=t.authenticated||!1,this.user=t.user||!1,this.nonces=t.nonces||{},e&&!this.authenticated&&(window.location.href=`/login?redirect_to=${encodeURIComponent(window.location.href)}`)}clearAuthData(){this.authenticated=!1,this.user=null,this.nonces={},sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}getCachedAuth(){try{const t=sessionStorage.getItem(this.storageKey),e=sessionStorage.getItem(this.cacheMetaKey);if(!t||!e)return null;const i=JSON.parse(e),s=JSON.parse(t);return Date.now()-i.timestamp>this.cacheExpiry?(this.clearCachedAuth(),null):s}catch(t){return console.error("Error reading cached auth:",t),null}}cacheAuth(t){try{sessionStorage.setItem(this.storageKey,JSON.stringify(t)),sessionStorage.setItem(this.cacheMetaKey,JSON.stringify({session_id:t.session_id||null,timestamp:Date.now()}))}catch(t){console.error("Error caching auth:",t)}}clearCachedAuth(){sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey)}async refresh(){this.isAuthenticating=!0,this.initialized=!1;try{await this.fetchAuth(),this.notify("auth-refreshed",{})}catch(t){console.error("Failed to refresh auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}getNonce(t="wp_rest"){return this.nonces[t]||""}getUser(){return this.user}isAuthenticated(){return this.authenticated}async handleLogin(t=null){if(sessionStorage.removeItem(this.storageKey),sessionStorage.removeItem(this.cacheMetaKey),t)return this.cacheAuth(t),this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!1,fromLogin:!0});await this.refresh()}handleLogout(){this.clearAuthData(),this.notify("logged-out",{})}subscribe(t){return this.subscribers.add(t),this.initialized&&t("auth-loaded",{fromCache:!1,immediate:!0}),()=>this.subscribers.delete(t)}notify(t,e){this.subscribers.forEach(i=>{try{i(t,e)}catch(t){console.error("Subscriber error:",t)}})}ready(){return this.initialized?Promise.resolve():new Promise(t=>{const e=this.subscribe(i=>{"auth-loaded"!==i&&"auth-error"!==i||(e(),t())})})}};
\ No newline at end of file
+window.auth=new class{constructor(){this.initialized=!1,this.isAuthenticating=!1,this.authenticated=!1,this.user=!1,this.nonces={},this.subscribers=new Set,this.cacheExpiry=3e5,this.init()}async init(){if(!this.isAuthenticating){this.isAuthenticating=!0;try{if("undefined"!=typeof jvbAuth)return this.setAuthData(jvbAuth),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!1});await this.fetchAuth()}catch(t){this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}}async refreshNonce(t="wp_rest"){try{return await this.fetchAuth(),this.getNonce(t)}catch(t){return console.error("Failed to refresh nonce:",t),null}}async fetch(t,i={}){const e=async(s=0)=>{const h={...!(i.body instanceof FormData)&&{"Content-Type":"application/json"},...i.headers,"X-WP-Nonce":this.getNonce()},n=await fetch(t,{...i,credentials:"same-origin",headers:h});if((403===n.status||401===n.status)&&0===s){const t=await n.clone().json();if("rest_cookie_invalid_nonce"===t.code||t.message?.includes("Cookie check"))return console.log("Nonce invalid, refreshing auth..."),await this.refresh(),e(s+1)}return n};return e()}async fetchAuth(){const t=await fetch(`${jvbSettings.api}auth/status`,{method:"GET",credentials:"same-origin",headers:{"Content-Type":"application/json"}});if(!t.ok)throw new Error("Auth check failed");const i=await t.json();this.setAuthData(i),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-loaded",{fromCache:!1})}setAuthData(t){const i=this.initialized&&this.authenticated;this.authenticated=t.authenticated||!1,this.user=t.user||!1,this.nonces=t.nonces||{},i&&!this.authenticated&&(window.location.href=`/login?redirect_to=${encodeURIComponent(window.location.href)}`)}clearAuthData(){this.authenticated=!1,this.user=null,this.nonces={}}async refresh(){this.isAuthenticating=!0,this.initialized=!1;try{await this.fetchAuth(),this.notify("auth-refreshed",{})}catch(t){console.error("Failed to refresh auth:",t),this.clearAuthData(),this.initialized=!0,this.isAuthenticating=!1,this.notify("auth-error",{error:t})}}getNonce(t="wp_rest"){return this.nonces[t]||""}getUser(){return this.user}isAuthenticated(){return this.authenticated}async handleLogin(t=null){if(t)return this.setAuthData(t),this.initialized=!0,this.isAuthenticating=!1,void this.notify("auth-loaded",{fromCache:!1,fromLogin:!0});await this.refresh()}handleLogout(){this.clearAuthData(),this.notify("logged-out",{})}subscribe(t){return this.subscribers.add(t),this.initialized&&t("auth-loaded",{fromCache:!1,immediate:!0}),()=>this.subscribers.delete(t)}notify(t,i){this.subscribers.forEach(e=>{try{e(t,i)}catch(t){console.error("Subscriber error:",t)}})}};
\ No newline at end of file
diff --git a/assets/js/min/queue.min.js b/assets/js/min/queue.min.js
index 08dbcb7..cdda0fd 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.user&&(this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.loadFromStorage=!1,this.failedFetches=0,this.api=jvbSettings.api,this.endpoint="queue",this.init())}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=window.jvbPopup.registerPopup({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 .m-actions .refresh",countdown:"#queue .m-actions .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),this.visibilityHandler=this.handleVisibilityChange.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),document.addEventListener("visibilitychange",this.visibilityHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleVisibilityChange(e){this.isPolling&&document.hidden?this.stopPolling():this.maybeStartPolling()}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,isAuth:!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 window.auth.fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:Array.isArray(e)?e:[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)return void this.stopActivityTracking();this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1);0===this.getQueueByStatus("queued").length?this.stopActivityTracking():this.trackActivity(),this.toggleQueue(this.maybeStartPolling())}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s,i=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(i=!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,s=e.data):(s={...e.data,id:e.id,user:window.auth.getUser()},t=JSON.stringify(s),e.headers["Content-Type"]="application/json"),"unknown"===e.endpoint||null==t)return;const n=await window.auth.fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t});console.log("Sending request with data: ",s);const r=await n.json();if(i&&(e.data={}),console.log("Result: ",r),!n.ok||!r.success)throw new Error(r.message||`HTTP ${n.status}`);this.notify("sent-to-server",s),r.id&&e.id!==r.id?e=await this.handleServerMerge(e,r):(e.status=r.status??"pending",e.serverData=r,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,r=s.updated_at??s.timestamp??0;return new Date(r)-new Date(n)})}getAllQueue(){let e=new Set,t=[...Array.from(this.queue.values())];return this.loadFromStorage||(this.loadFromStorage=!0,t=[...t,...Array.from(this.store.data.values())],t=t.filter(t=>{const s=e.has(t.id);return e.add(t.id),!s})),this.sortOperations(t)}getQueueByStatus(e){return"string"==typeof e&&(e=[e]),this.getAllQueue().filter(t=>e.includes(t.status))}updateOperationStatus(e,t){let s=this.getQueue(e);s&&(this.statuses.includes(t)?(s.status=t,this.notify("operation-status",s),this.setQueue(s)):console.log("Invalid status: ",t))}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(),429===(await this.store.fetch()).status)return console.log("Too many requests. Waiting 30 seconds"),this.stopPolling(),void this.startCountdown(30,()=>this.runPollCycle());if(this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){this.stopPolling(),this.updatePanel("synced"),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;let t=e.filter(e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status));t=t.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 r="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!r,r){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(void 0!==e.progress_percentage)return e.progress_percentage;if(void 0!==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",merged:"Merged"}[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":if(e.count&&void 0!==e.progress_count){const t=e.progress_count,s=e.count;return`Processing ${t}/${s} items (${Math.round(t/s*100)}%)`}return void 0!==e.progress_percentage?`${e.progress_percentage}% complete`:"Processing...";case"completed":return"Successfully completed. Refresh to see changes.";case"merged":return e.merged_into?`Merged with another operation (${e.merged_into.substring(0,8)}...)`:"Merged with another operation";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){const s={...t,...e,endpoint:t.endpoint,method:t.method,headers:t.headers,progress_percentage:e.progress_percentage,progress_count:e.progress_count,count:e.count};e.merged_into&&this.handleMergedOperation(s)}const s=e.type?e.type.replace("_update","").replace("_","/"):"unknown",i={...e,endpoint:s,method:"POST",headers:{...this.headers}};return e.merged_into&&this.handleMergedOperation(i),i}handleMergedOperation(e){e.merged_into&&(console.log(`[Queue] Operation ${e.id} merged into ${e.merged_into}`),setTimeout(()=>{this.clearQueue(e.id),this.removeOperationFromUI(e.id)},3e3))}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.user&&(this.canUpdateUI=!0,this.isProcessing=!1,this.isPolling=!1,this.queue=new Map,this.items=new Map,this.subscribers=new Set,this.loadFromStorage=!1,this.failedFetches=0,this.api=jvbSettings.api,this.endpoint="queue",this.init())}init(){this.headers={"X-WP-Nonce":window.auth.getNonce()},this.initElements(),this.initListeners(),this.initStore(),this.canUpdateUI&&this.ui.panel&&(this.popup=window.jvbPopup.registerPopup({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 .m-actions .refresh",countdown:"#queue .m-actions .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),this.visibilityHandler=this.handleVisibilityChange.bind(this),document.addEventListener("click",this.clickHandler),window.addEventListener("online",this.onlineHandler),window.addEventListener("offline",this.offlineHandler),document.addEventListener("visibilitychange",this.visibilityHandler)}handleOnline(){this.updatePanel("synced"),this.getQueueByStatus(this.pendingStatuses).length>0&&this.processQueue()}handleOffline(){this.updatePanel("offline")}handleVisibilityChange(e){this.isPolling&&document.hidden?this.stopPolling():this.maybeStartPolling()}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,isAuth:!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 window.auth.fetch(`${this.api}${this.endpoint}`,{method:"POST",headers:{"Content-Type":"application/json",...this.headers},body:JSON.stringify({action:t,ids:Array.isArray(e)?e:[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)return void this.stopActivityTracking();this.setProcessing();for(const t of e)await this.processOperation(t);this.setProcessing(!1);0===this.getQueueByStatus("queued").length?this.stopActivityTracking():this.trackActivity(),this.toggleQueue(this.maybeStartPolling())}async processOperation(e){try{this.queue.has(e.id)||this.queue.set(e.id,e);let t,s,i=!1;if(e.data?._isFormData&&!e.data instanceof FormData&&(i=!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,s=e.data):(s={...e.data,id:e.id,user:window.auth.getUser()},t=JSON.stringify(s),e.headers["Content-Type"]="application/json"),"unknown"===e.endpoint||null==t)return;const n=await window.auth.fetch(`${this.api}${e.endpoint}`,{method:e.method,headers:e.headers,body:t}),r=await n.json();if(i&&(e.data={}),!n.ok||!r.success)throw new Error(r.message||`HTTP ${n.status}`);this.notify("sent-to-server",s),r.id&&e.id!==r.id?e=await this.handleServerMerge(e,r):(e.status=r.status??"pending",e.serverData=r,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,r=s.updated_at??s.timestamp??0;return new Date(r)-new Date(n)})}getAllQueue(){let e=new Set,t=[...Array.from(this.queue.values())];return this.loadFromStorage||(this.loadFromStorage=!0,t=[...t,...Array.from(this.store.data.values())],t=t.filter(t=>{const s=e.has(t.id);return e.add(t.id),!s})),this.sortOperations(t)}getQueueByStatus(e){return"string"==typeof e&&(e=[e]),this.getAllQueue().filter(t=>e.includes(t.status))}updateOperationStatus(e,t){let s=this.getQueue(e);s&&(this.statuses.includes(t)?(s.status=t,this.notify("operation-status",s),this.setQueue(s)):console.log("Invalid status: ",t))}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(),429===(await this.store.fetch()).status)return console.log("Too many requests. Waiting 30 seconds"),this.stopPolling(),void this.startCountdown(30,()=>this.runPollCycle());if(this.ui.refresh.button.classList.remove("fetching"),!this.maybeStartPolling())return this.stopPolling(),void this.updatePanel("synced")}catch(e){this.stopPolling(),this.updatePanel("synced"),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;let t=e.filter(e=>[...this.pendingStatuses,...this.workingStatuses].includes(e.status));t=t.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 r="completed"===s.status&&(s.completed_at||s.updated_at);if(t.ui.completed.wrap.hidden=!r,r){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(void 0!==e.progress_percentage)return e.progress_percentage;if(void 0!==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",merged:"Merged"}[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":if(e.count&&void 0!==e.progress_count){const t=e.progress_count,s=e.count;return`Processing ${t}/${s} items (${Math.round(t/s*100)}%)`}return void 0!==e.progress_percentage?`${e.progress_percentage}% complete`:"Processing...";case"completed":return"Successfully completed. Refresh to see changes.";case"merged":return e.merged_into?`Merged with another operation (${e.merged_into.substring(0,8)}...)`:"Merged with another operation";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){const s={...t,...e,endpoint:t.endpoint,method:t.method,headers:t.headers,progress_percentage:e.progress_percentage,progress_count:e.progress_count,count:e.count};e.merged_into&&this.handleMergedOperation(s)}const s=e.type?e.type.replace("_update","").replace("_","/"):"unknown",i={...e,endpoint:s,method:"POST",headers:{...this.headers}};return e.merged_into&&this.handleMergedOperation(i),i}handleMergedOperation(e){e.merged_into&&(console.log(`[Queue] Operation ${e.id} merged into ${e.merged_into}`),setTimeout(()=>{this.clearQueue(e.id),this.removeOperationFromUI(e.id)},3e3))}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/referral.min.js b/assets/js/min/referral.min.js
index 528347c..75c6822 100644
--- a/assets/js/min/referral.min.js
+++ b/assets/js/min/referral.min.js
@@ -1 +1 @@
-(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'}},this.forms=this.container.querySelectorAll("form"),this.popup=window.jvbPopup.registerPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=window.jvbTabs.registerTab(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach(e=>{e.remove()})}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!1,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!1,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach(e=>{e[t]("submit",this.submitHandler)}),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach(([e,t])=>{const r=this.container.querySelector(`[data-stat="${e}"]`);r&&(r.textContent=t)});const r=this.container.querySelectorAll(".stats .card");r.length>=4&&(r[0].querySelector(".stat-number").textContent=t.code_used,r[1].querySelector(".stat-number").textContent=t.consultations,r[2].querySelector(".stat-number").textContent=t.treatments,r[3].querySelector(".stat-number").textContent=t.total_rewards)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,r=this.container.querySelector(`#${t}`);if(!r)return;const s=r.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then(()=>{e.classList.toggle("success"),setTimeout(()=>{e.classList.remove("success")},1500)})}handleError(e,t){const{message:r,code:s,field:a}=t;switch(a?this.showFieldError(e,a,r):this.showFormStatus(e,"error",r||"Something went wrong. Please try again."),s){case"duplicate_email":break;case"invalid_code":const t=e.querySelector('[name="referral_code"]');t&&(t.readOnly=!1,t.focus());break;case"turnstile_failed":window.turnstile&&e.querySelector(".cf-turnstile")&&window.turnstile.reset()}}showFieldError(e,t,r){let s=e.querySelector(`.field[data-field="${t}"]`);if(s||(s=e.querySelector(`.field[data-field="referral_${t}"]`)),!s)return void this.showFormStatus(e,"error",r);const a=s.querySelector("input, textarea, select"),i=s.querySelector(".validation-message"),n=s.querySelector(".validation-icon.error"),o=s.querySelector(".validation-icon.success");a?(s.classList.remove("has-success"),s.classList.add("has-error"),a.classList.add("error"),a.setAttribute("aria-invalid","true"),n&&(n.hidden=!1),o&&(o.hidden=!0),i&&(i.textContent=r,i.hidden=!1),a.focus(),this.a11y?.announce(`Error in ${t}: ${r}`)):this.showFormStatus(e,"error",r)}showFormStatus(e,t,r=""){const s=e.querySelector(".fstatus");if(!s)return void console.warn("No .fstatus element found in form");s.hidden=!1;const a=s.querySelector(".message");s.querySelector(".icon")?.remove(),s.querySelector(".actions")?.remove();const i={saving:"Sending...",submitted:"Sent successfully!",error:"Something went wrong",checking:"Checking code..."},n={submitted:"check-circle",error:"close-circle",checking:"loading"};if(n[t]&&window.getIcon){const e=window.getIcon(n[t]);e&&s.prepend(e)}a&&(a.textContent=r||i[t]||t),s.classList.toggle("loading",["saving","checking"].includes(t)),"submitted"===t&&setTimeout(()=>s.hidden=!0,3e3),this.a11y&&this.a11y.announce(r||i[t]||t)}clearFormErrors(e){e.querySelectorAll(".field.has-error, .field.has-success").forEach(e=>{this.clearFieldValidation(e)});const t=e.querySelector(".fstatus");t&&(t.hidden=!0)}clearFieldValidation(e){if(!e)return;const t=e.querySelector("input, textarea, select"),r=e.querySelector(".validation-message"),s=e.querySelectorAll(".validation-icon");e.classList.remove("has-error","has-success"),t&&(t.classList.remove("error"),t.removeAttribute("aria-invalid")),s.forEach(e=>e.hidden=!0),r&&(r.hidden=!0,r.textContent="")}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase());const t=e.target.closest(".field");t&&t.classList.contains("has-error")&&this.clearFieldValidation(t)}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),r=t.querySelector('[name="referral_code"]'),s=t.querySelector(".code-status");if(!r||!s)return;const a=r.value.trim();if(a){s.hidden=!1,s.className="code-status loading",s.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(s,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(s,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(s,"Error checking code","error")}}else this.showCodeStatus(s,"Please enter a code","error")}showCodeStatus(e,t,r){e.hidden=!1,e.className=`code-status ${r}`,e.textContent=t,"error"===r&&setTimeout(()=>{e.hidden=!0},5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),r=this.getUrlParameter("remail"),s=this.getUrlParameter("seeReferral");if(!e&&!s)return;if(s&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const i=e.toUpperCase();if(a.value=i,a.readOnly=!0,t||r){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const s=this.container.querySelector('[name="referral_email"]');s&&(s.value=r)}this.popup.openPopup();try{const e=await this.validateCodeOnly(i);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&!r.value&&r.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map(e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}async handleFormSubmit(e){e.preventDefault();const t=e.target,r=new FormData(t);this.clearFormErrors(t),this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){let s={name:r.get("referral_name"),email:r.get("referral_email"),referral_code:r.get("referral_code")};const a=t.querySelector('input[name="cf-turnstile-response"]');a&&a.value&&(s["cf-turnstile-response"]=a.value),s.name&&s.email&&s.referral_code?e=await this.makeRequest("auth/register",s):e.message="Please fill in all fields"}else if("login-form"===t.id){let s={type:"login",user_email:r.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};const a=t.querySelector('input[name="cf-turnstile-response"]');a&&a.value&&(s["cf-turnstile-response"]=a.value),s.user_email?e=await this.makeRequest("auth/magic",s):e.message="Please fill in your email"}e.success?this.handleSuccess(t,e):this.handleError(t,e)}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["auth/magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const r=await window.auth.fetch(`${jvbSettings.api}${e}`,{method:"POST",body:JSON.stringify(t)});if(!r.ok){const e=await r.text();console.error("Error response:",r.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await r.json()}handleSuccess(e,t){e.style.display="none";const r=e.nextElementSibling;r&&r.classList.contains("success-content")&&(r.hidden=!1,r.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,r="error"){const s=e.querySelector(".status");if(!s)return;const a=s.querySelector(".message");a&&(a.textContent=t),s.hidden=!1,s.className=`status ${r}`,"error"===r&&setTimeout(()=>{s.hidden=!0},5e3)}setFormLoading(e,t){t.querySelectorAll("input, button, textarea, select").forEach(t=>t.disabled=e),e&&this.showFormStatus(t,"saving")}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",async function(){window.auth.subscribe(t=>{"auth-loaded"===t&&(window.jvbReferral=new e)})})})();
\ No newline at end of file
+(()=>{class e{constructor(){this.container=document.querySelector("aside.referral"),this.container&&(this.a11y=window.jvbA11y,this.toggle=document.querySelector('button[data-action="toggle-referral"]'),this.hasCopy=navigator.clipboard&&navigator.clipboard.writeText,this.initElements(),this.initStore(),this.initListeners(),this.checkForReferral())}initElements(){this.selectors={copyBtn:".copy-btn",checkCode:".check-code-btn",submit:"[type=submit]",recentList:".recent-referrals-list",stats:{codeUsed:'[data-stat="code_used"]',consultations:'[data-stat="consultations"]',treatments:'[data-stat="treatments"]',rewards:'[data-stat="total_rewards"]'}},this.forms=this.container.querySelectorAll("form"),this.popup=window.jvbPopup.registerPopup({toggle:this.toggle,popup:this.container,name:"Referral Box",onOpen:()=>{this.bindEventListeners(!0)},onClose:()=>{this.bindEventListeners(!1)}}),this.tabs=null,this.container.querySelector("nav.tabs")&&(this.tabs=window.jvbTabs.registerTab(this.container,{updateURL:!1})),this.ui=window.uiFromSelectors(this.selectors),this.hasCopy||document.querySelectorAll(this.selectors.copyBtn).forEach(e=>{e.remove()})}initStore(){if(!this.isLoggedIn())return;const e=window.jvbStore.register("referrals",[{storeName:"stats",keyPath:"user_id",endpoint:"referrals/stats",TTL:3e5,showLoading:!1,delayFetch:!0,filters:{type:"dashboard",user:window.auth.getUser()}},{storeName:"list",keyPath:"id",endpoint:"referrals",TTL:6e5,showLoading:!1,delayFetch:!0,filters:{user:window.auth.getUser(),status:"all",limit:50,offset:0}}]);this.statsStore=e.stats,this.listStore=e.list,this.statsStore&&this.statsStore.subscribe(this.handleStatsEvent.bind(this)),this.listStore&&this.listStore.subscribe(this.handleListEvent.bind(this))}initListeners(){this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleFormSubmit.bind(this)}bindEventListeners(e){const t=e?"addEventListener":"removeEventListener";this.forms.forEach(e=>{e[t]("submit",this.submitHandler)}),this.container[t]("click",this.clickHandler),this.container[t]("input",this.inputHandler)}isLoggedIn(){return Boolean(window.auth.getUser())}handleStatsEvent(e,t){switch(e){case"data-loaded":t.items&&t.items.length>0&&this.updateStatsDisplay();break;case"fetch-error":console.error("Error loading stats:",t.error)}}handleListEvent(e,t){switch(e){case"data-loaded":this.ui.recentList&&this.renderRecentReferrals();break;case"fetch-error":console.error("Error loading referrals:",t.error)}}updateStatsDisplay(){if(0===!this.statsStore.data.size)return;let e=this.statsStore.data.get(parseInt(window.auth.getUser()));const t={total:e.code_used||0,treated:e.treatments||0,pending:e.pending||0,rewards:"$"+parseFloat(e.total_rewards||0).toFixed(2)};Object.entries(t).forEach(([e,t])=>{const r=this.container.querySelector(`[data-stat="${e}"]`);r&&(r.textContent=t)});const r=this.container.querySelectorAll(".stats .card");r.length>=4&&(r[0].querySelector(".stat-number").textContent=t.code_used,r[1].querySelector(".stat-number").textContent=t.consultations,r[2].querySelector(".stat-number").textContent=t.treatments,r[3].querySelector(".stat-number").textContent=t.total_rewards)}handleClick(e){const t=e.target.closest(".copy-btn, .check-code-btn, .attn");t&&(t.classList.contains("copy-btn")?this.handleCopyClick(t):t.classList.contains("check-code-btn")?this.handleCheckCode(e):t.classList.contains("attn")&&t.classList.remove("attn"))}handleCopyClick(e){const t=e.dataset.target,r=this.container.querySelector(`#${t}`);if(!r)return;const s=r.textContent.trim();this.hasCopy&&navigator.clipboard.writeText(s).then(()=>{e.classList.toggle("success"),setTimeout(()=>{e.classList.remove("success")},1500)})}handleError(e,t){const{message:r,code:s,field:a}=t;switch(a?this.showFieldError(e,a,r):this.showFormStatus(e,"error",r||"Something went wrong. Please try again."),s){case"duplicate_email":break;case"invalid_code":const t=e.querySelector('[name="referral_code"]');t&&(t.readOnly=!1,t.focus());break;case"turnstile_failed":window.turnstile&&e.querySelector(".cf-turnstile")&&window.turnstile.reset()}}showFieldError(e,t,r){let s=e.querySelector(`.field[data-field="${t}"]`);if(s||(s=e.querySelector(`.field[data-field="referral_${t}"]`)),!s)return void this.showFormStatus(e,"error",r);const a=s.querySelector("input, textarea, select"),i=s.querySelector(".validation-message"),n=s.querySelector(".validation-icon.error"),o=s.querySelector(".validation-icon.success");a?(s.classList.remove("has-success"),s.classList.add("has-error"),a.classList.add("error"),a.setAttribute("aria-invalid","true"),n&&(n.hidden=!1),o&&(o.hidden=!0),i&&(i.textContent=r,i.hidden=!1),a.focus(),this.a11y?.announce(`Error in ${t}: ${r}`)):this.showFormStatus(e,"error",r)}showFormStatus(e,t,r=""){const s=e.querySelector(".fstatus");if(!s)return void console.warn("No .fstatus element found in form");s.hidden=!1;const a=s.querySelector(".message");s.querySelector(".icon")?.remove(),s.querySelector(".actions")?.remove();const i={saving:"Sending...",submitted:"Sent successfully!",error:"Something went wrong",checking:"Checking code..."},n={submitted:"check-circle",error:"close-circle",checking:"loading"};if(n[t]&&window.getIcon){const e=window.getIcon(n[t]);e&&s.prepend(e)}a&&(a.textContent=r||i[t]||t),s.classList.toggle("loading",["saving","checking"].includes(t)),"submitted"===t&&setTimeout(()=>s.hidden=!0,3e3),this.a11y&&this.a11y.announce(r||i[t]||t)}clearFormErrors(e){e.querySelectorAll(".field.has-error, .field.has-success").forEach(e=>{this.clearFieldValidation(e)});const t=e.querySelector(".fstatus");t&&(t.hidden=!0)}clearFieldValidation(e){if(!e)return;const t=e.querySelector("input, textarea, select"),r=e.querySelector(".validation-message"),s=e.querySelectorAll(".validation-icon");e.classList.remove("has-error","has-success"),t&&(t.classList.remove("error"),t.removeAttribute("aria-invalid")),s.forEach(e=>e.hidden=!0),r&&(r.hidden=!0,r.textContent="")}handleInput(e){"referral_code"!==e.target.id&&"referral_code"!==e.target.name||(e.target.value=e.target.value.toUpperCase());const t=e.target.closest(".field");t&&t.classList.contains("has-error")&&this.clearFieldValidation(t)}async handleCheckCode(e){e.preventDefault();const t=e.target.closest("form"),r=t.querySelector('[name="referral_code"]'),s=t.querySelector(".code-status");if(!r||!s)return;const a=r.value.trim();if(a){s.hidden=!1,s.className="code-status loading",s.innerHTML='<span class="spinner"></span> Checking...';try{const e=await this.validateCodeOnly(a);e.success?this.showCodeStatus(s,`✓ Valid! Referred by ${e.referrer_name}`,"success"):this.showCodeStatus(s,e.message||"Invalid code","error")}catch(e){console.error("Error checking code:",e),this.showCodeStatus(s,"Error checking code","error")}}else this.showCodeStatus(s,"Please enter a code","error")}showCodeStatus(e,t,r){e.hidden=!1,e.className=`code-status ${r}`,e.textContent=t,"error"===r&&setTimeout(()=>{e.hidden=!0},5e3)}async checkForReferral(){const e=this.getUrlParameter("ref"),t=this.getUrlParameter("rname"),r=this.getUrlParameter("remail"),s=this.getUrlParameter("seeReferral");if(!e&&!s)return;if(s&&!e)return this.popup.openPopup(),void this.removeUrlParameter("seeReferral");const a=this.container.querySelector('[name="referral_code"]');if(!a)return;const i=e.toUpperCase();if(a.value=i,a.readOnly=!0,t||r){const e=this.container.querySelector('[name="referral_name"]');e&&(e.value=t);const s=this.container.querySelector('[name="referral_email"]');s&&(s.value=r)}this.popup.openPopup();try{const e=await this.validateCodeOnly(i);if(e.success){const t=a.closest("form").querySelector(".code-status");t&&this.showCodeStatus(t,`✓ ${e.referrer_name} invited you!`,"success");const r=this.container.querySelector('[name="referral_name"]');r&&!r.value&&r.focus()}else a.readOnly=!1,this.showMessage("This referral link is invalid. Please enter a valid code.","error")}catch(e){console.error("Error validating code:",e),a.readOnly=!1}this.removeUrlParameter("ref"),this.removeUrlParameter("rname"),this.removeUrlParameter("remail")}getUrlParameter(e){return new URLSearchParams(window.location.search).get(e)}removeUrlParameter(e){const t=new URL(window.location);t.searchParams.delete(e),window.history.replaceState({},document.title,t.toString())}async validateCodeOnly(e){const t=await fetch(`${jvbSettings.api}referrals/code`,{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":window.auth.getNonce()},body:JSON.stringify({code:e})});return await t.json()}renderRecentReferrals(){let e=this.ui.recentList,t=Array.from(this.listStore.data.values());t&&0!==t.length?e.innerHTML=t.map(e=>`\n\t\t\t<div class="referral-item">\n\t\t\t\t<div class="referral-info">\n\t\t\t\t\t<strong>${window.escapeHtml(e.referee_name)}</strong>\n\t\t\t\t\t<span class="status-badge">${e.referral_status}</span>\n\t\t\t\t</div>\n\t\t\t\t<div class="referral-date">${window.formatTimeAgo(e.referred_at)}</div>\n\t\t\t</div>\n\t\t`).join(""):e.innerHTML='<p class="no-referrals">Share your code to get started!</p>'}async handleFormSubmit(e){e.preventDefault();const t=e.target,r=new FormData(t);this.clearFormErrors(t),this.setFormLoading(!0,t);try{let e={success:!1,message:""};if("referral-code-form"===t.id){let s={name:r.get("referral_name"),email:r.get("referral_email"),referral_code:r.get("referral_code")};const a=t.querySelector('input[name="cf-turnstile-response"]');a&&a.value&&(s["cf-turnstile-response"]=a.value),s.name&&s.email&&s.referral_code?e=await this.makeRequest("auth/register",s):e.message="Please fill in all fields"}else if("login-form"===t.id){let s={type:"login",user_email:r.get("login_email"),context:{redirect_to:window.location.href+"?seeReferral=1"}};const a=t.querySelector('input[name="cf-turnstile-response"]');a&&a.value&&(s["cf-turnstile-response"]=a.value),s.user_email?e=await this.makeRequest("auth/magic",s):e.message="Please fill in your email"}e.success?this.handleSuccess(t,e):this.handleError(t,e)}catch(e){console.error("Error submitting form:",e),this.showFormMessage(t,"Something went wrong. Please try again.","error")}finally{this.setFormLoading(!1,t)}}async makeRequest(e,t){if(!["auth/magic","auth/register"].includes(e))return{success:!1,message:"Invalid endpoint"};const r=await window.auth.fetch(`${jvbSettings.api}${e}`,{method:"POST",body:JSON.stringify(t)});if(!r.ok){const e=await r.text();console.error("Error response:",r.status,e);try{return JSON.parse(e)}catch{return{success:!1,message:"Server error"}}}return await r.json()}handleSuccess(e,t){e.style.display="none";const r=e.nextElementSibling;r&&r.classList.contains("success-content")&&(r.hidden=!1,r.scrollIntoView({behavior:"smooth",block:"center"})),this.dispatchEvent("emailSent",{email:t.email})}showFormMessage(e,t,r="error"){const s=e.querySelector(".status");if(!s)return;const a=s.querySelector(".message");a&&(a.textContent=t),s.hidden=!1,s.className=`status ${r}`,"error"===r&&setTimeout(()=>{s.hidden=!0},5e3)}setFormLoading(e,t){t.querySelectorAll("input, button, textarea, select").forEach(t=>t.disabled=e),e&&this.showFormStatus(t,"saving")}dispatchEvent(e,t){const r=new CustomEvent("referralWidget:"+e,{detail:t,bubbles:!0});this.container.dispatchEvent(r)}}document.addEventListener("DOMContentLoaded",async function(){window.auth.subscribe(t=>{"auth-loaded"===t&&(window.jvbReferral=new e)})})})();
\ No newline at end of file
diff --git a/build/feed/block.json b/build/feed/block.json
index d8b7aff..a1e0f3a 100644
--- a/build/feed/block.json
+++ b/build/feed/block.json
@@ -10,7 +10,7 @@
     "feed",
     "grid"
   ],
-  "version": "0.9.0",
+  "version": "1.0.0",
   "textdomain": "jvb",
   "supports": {
     "html": false,
diff --git a/build/feed/style-index-rtl.css b/build/feed/style-index-rtl.css
index 198f690..7791d84 100644
--- a/build/feed/style-index-rtl.css
+++ b/build/feed/style-index-rtl.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .placeholder{align-items:center;aspect-ratio:1;background:rgb(var(--base));border:1rem solid rgb(var(--base-50));border-radius:1rem;display:flex;justify-content:center;--w:50%;color:rgb(var(--base-200))}.feed-block .placeholder i.icon{animation:dance 2.5s ease-in-out infinite}.feed-block .item-grid{max-width:var(--full)}.feed-block .item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.feed-block .item{background:rgb(var(--base-50));box-shadow:rgba(var(--base),var(--op-2)) var(--shdw);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0}.feed-block .item h3{font-size:var(--txt-medium);margin:0}.feed-block .item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed-block .item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base),var(--op-2));right:0;position:absolute;top:calc(var(--chip_)*-1);width:100%}.feed-block .item details summary:hover{background-color:rgba(var(--action-0),var(--op-45))}.feed-block .item details[open]{padding:.25rem .5rem}.feed-block .item img:hover{opacity:.8}.feed-block .item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed-block .item[data-timeline] .images span{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast));padding:.25rem .5rem;position:absolute;width:50%}.feed-block .item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed-block .item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed-block .item[data-timeline] .images img{width:50%}.feed-block .item[data-timeline] .images img:first-of-type{border-left:2px solid rgb(var(--action-0))}.feed-block .item a:after,.feed-block .item a:before{display:none}.feed-block .item label{font-weight:400;text-transform:none;--w:1.5em}.feed-block .all-filters summary{display:flex;justify-content:space-between}.all-filters{font-size:var(--txt-x-small)}.all-filters[open]{border:2px solid rgb(var(--action-0));border-radius:0 0 var(--radius-outer) var(--radius-outer);padding:0}.all-filters summary{width:100%}.all-filters summary:hover,.all-filters[open] summary{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast))}.all-filters summary:hover:after,.all-filters[open] summary:after{background-color:rgb(var(--action-contrast))}.all-filters>.row.row{padding:0 .75rem 2rem;width:var(--content)}.all-filters>.row.row.search{padding-bottom:0}.all-filters>.row.row{position:relative}.all-filters>.row.row>.label,.all-filters>.row.row>.row>.label{font-family:var(--heading);font-weight:var(--fw-h-bold);text-transform:uppercase}.all-filters>.row.row>.label{width:20%}.all-filters>.row.row>.row>.label{white-space:nowrap}.all-filters .btn+label,.all-filters button{min-height:var(--chipchip);padding:0;width:var(--chipchip)}.all-filters .btn+label .label,.all-filters .row:has(>.btn:not(:checked)+label:hover) :checked+label .label,.all-filters button .label{bottom:-2rem;opacity:0;position:absolute;white-space:nowrap;width:-moz-max-content;width:max-content;z-index:var(--z-4)}.all-filters .btn+label:hover .label,.all-filters .btn:checked+label .label,.all-filters button:hover .label{opacity:1}.all-filters .search.row,.all-filters .view.row{display:none}.all-filters .ordering{padding:2rem 0 .75rem}.all-filters .ordering>.row label{position:unset}.all-filters .ordering .row .label{color:rgb(var(--contrast));top:-1rem}.all-filters .ordering .row.orderby .label{right:0}.all-filters .ordering .row.order-direction .label{left:0}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{padding:0;transform:scaleX(0);transform-origin:right;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base);width:0}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:right;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}.toggle-text input+label{border:1px dashed transparent;color:var(--contrast)!important;cursor:pointer;font-weight:400;padding:.25rem .5rem;position:relative;text-transform:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.toggle-text input+label:after,.toggle-text input+label:before{display:none!important}.toggle-text input+label .text{margin:0;position:relative;--gap:0;border:1px solid rgb(var(--action-50));border-radius:var(--radius);color:rgb(var(--action-50));font-weight:700;padding:2px 4px;width:-moz-fit-content;width:fit-content}.toggle-text input+label .off{--mid:-100%}.toggle-text input+label .on{--mid:100%}.toggle-text input+label .off,.toggle-text input+label .on{transition:var(--trans-transform),opacity var(--trans-base)}.toggle-text input+label .off,.toggle-text input:checked+label .on{max-width:100%;opacity:1;transform:translateZ(0)}.toggle-text input+label .on,.toggle-text input:checked+label .off{max-width:0;opacity:0;transform:translate3d(0,var(--mid),0)}.toggle-text:hover label{border-color:rgb(var(--action-200))}.toggle-text:hover .text{background-color:rgb(var(--action-50));border-color:rgb(var(--action-50));color:rgb(var(--action-contrast))}
+.feed-block{grid-column:full}.feed-block .placeholder{align-items:center;aspect-ratio:1;background:rgb(var(--base));border:1rem solid rgb(var(--base-50));border-radius:1rem;display:flex;justify-content:center;--w:50%;color:rgb(var(--base-200))}.feed-block .placeholder i.icon{animation:dance 2.5s ease-in-out infinite}.feed-block .item-grid{max-width:var(--full)}.feed-block .item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.feed-block .item{background:rgb(var(--base-50));box-shadow:rgba(var(--base),var(--op-2)) var(--shdw);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0}.feed-block .item h3{font-size:var(--txt-medium);margin:0}.feed-block .item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed-block .item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base),var(--op-2));right:0;position:absolute;top:calc(var(--chip_)*-1);width:100%}.feed-block .item details summary:hover{background-color:rgba(var(--action-0),var(--op-45))}.feed-block .item details[open]{padding:.25rem .5rem}.feed-block .item img:hover{opacity:.8}.feed-block .item ul{margin:0}.feed-block .item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed-block .item[data-timeline] .images span{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast));padding:.25rem .5rem;position:absolute;width:50%}.feed-block .item[data-timeline] .images span:first-of-type{bottom:0;left:50%;text-align:left}.feed-block .item[data-timeline] .images span:last-of-type{right:50%;top:0}.feed-block .item[data-timeline] .images img{width:50%}.feed-block .item[data-timeline] .images img:first-of-type{border-left:2px solid rgb(var(--action-0))}.feed-block .item a:after,.feed-block .item a:before{display:none}.feed-block .item label{font-weight:400;text-transform:none;--w:1.5em}.feed-block .all-filters summary{display:flex;justify-content:space-between}.all-filters{font-size:var(--txt-x-small)}.all-filters[open]{border:2px solid rgb(var(--action-0));border-radius:0 0 var(--radius-outer) var(--radius-outer);padding:0}.all-filters summary{width:100%}.all-filters summary:hover,.all-filters[open] summary{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast))}.all-filters summary:hover:after,.all-filters[open] summary:after{background-color:rgb(var(--action-contrast))}.all-filters>.row.row{padding:0 .75rem 2rem;width:var(--content)}.all-filters>.row.row.search{padding-bottom:0}.all-filters>.row.row{position:relative}.all-filters>.row.row>.label,.all-filters>.row.row>.row>.label{font-family:var(--heading);font-weight:var(--fw-h-bold);text-transform:uppercase}.all-filters>.row.row>.label{width:20%}.all-filters>.row.row>.row>.label{white-space:nowrap}.all-filters .btn+label,.all-filters button{min-height:var(--chipchip);padding:0;width:var(--chipchip)}.all-filters .btn+label .label,.all-filters .row:has(>.btn:not(:checked)+label:hover) :checked+label .label,.all-filters button .label{bottom:-2rem;opacity:0;position:absolute;white-space:nowrap;width:-moz-max-content;width:max-content;z-index:var(--z-4)}.all-filters .btn+label:hover .label,.all-filters .btn:checked+label .label,.all-filters button:hover .label{opacity:1}.all-filters .search.row,.all-filters .view.row{display:none}.all-filters .ordering{padding:2rem 0 .75rem}.all-filters .ordering>.row label{position:unset}.all-filters .ordering .row .label{color:rgb(var(--contrast));top:-1rem}.all-filters .ordering .row.orderby .label{right:0}.all-filters .ordering .row.order-direction .label{left:0}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{padding:0;transform:scaleX(0);transform-origin:right;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base);width:0}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:right;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}.toggle-text input+label{border:1px dashed transparent;color:var(--contrast)!important;cursor:pointer;font-weight:400;padding:.25rem .5rem;position:relative;text-transform:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.toggle-text input+label:after,.toggle-text input+label:before{display:none!important}.toggle-text input+label .text{margin:0;position:relative;--gap:0;border:1px solid rgb(var(--action-50));border-radius:var(--radius);color:rgb(var(--action-50));font-weight:700;padding:2px 4px;width:-moz-fit-content;width:fit-content}.toggle-text input+label .off{--mid:-100%}.toggle-text input+label .on{--mid:100%}.toggle-text input+label .off,.toggle-text input+label .on{transition:var(--trans-transform),opacity var(--trans-base)}.toggle-text input+label .off,.toggle-text input:checked+label .on{max-width:100%;opacity:1;transform:translateZ(0)}.toggle-text input+label .on,.toggle-text input:checked+label .off{max-width:0;opacity:0;transform:translate3d(0,var(--mid),0)}.toggle-text:hover label{border-color:rgb(var(--action-200))}.toggle-text:hover .text{background-color:rgb(var(--action-50));border-color:rgb(var(--action-50));color:rgb(var(--action-contrast))}
diff --git a/build/feed/style-index.css b/build/feed/style-index.css
index 6c9880f..aabd955 100644
--- a/build/feed/style-index.css
+++ b/build/feed/style-index.css
@@ -1 +1 @@
-.feed-block{grid-column:full}.feed-block .placeholder{align-items:center;aspect-ratio:1;background:rgb(var(--base));border:1rem solid rgb(var(--base-50));border-radius:1rem;display:flex;justify-content:center;--w:50%;color:rgb(var(--base-200))}.feed-block .placeholder i.icon{animation:dance 2.5s ease-in-out infinite}.feed-block .item-grid{max-width:var(--full)}.feed-block .item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.feed-block .item{background:rgb(var(--base-50));box-shadow:rgba(var(--base),var(--op-2)) var(--shdw);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0}.feed-block .item h3{font-size:var(--txt-medium);margin:0}.feed-block .item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed-block .item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base),var(--op-2));left:0;position:absolute;top:calc(var(--chip_)*-1);width:100%}.feed-block .item details summary:hover{background-color:rgba(var(--action-0),var(--op-45))}.feed-block .item details[open]{padding:.25rem .5rem}.feed-block .item img:hover{opacity:.8}.feed-block .item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed-block .item[data-timeline] .images span{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast));padding:.25rem .5rem;position:absolute;width:50%}.feed-block .item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed-block .item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed-block .item[data-timeline] .images img{width:50%}.feed-block .item[data-timeline] .images img:first-of-type{border-right:2px solid rgb(var(--action-0))}.feed-block .item a:after,.feed-block .item a:before{display:none}.feed-block .item label{font-weight:400;text-transform:none;--w:1.5em}.feed-block .all-filters summary{display:flex;justify-content:space-between}.all-filters{font-size:var(--txt-x-small)}.all-filters[open]{border:2px solid rgb(var(--action-0));border-radius:0 0 var(--radius-outer) var(--radius-outer);padding:0}.all-filters summary{width:100%}.all-filters summary:hover,.all-filters[open] summary{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast))}.all-filters summary:hover:after,.all-filters[open] summary:after{background-color:rgb(var(--action-contrast))}.all-filters>.row.row{padding:0 .75rem 2rem;width:var(--content)}.all-filters>.row.row.search{padding-bottom:0}.all-filters>.row.row{position:relative}.all-filters>.row.row>.label,.all-filters>.row.row>.row>.label{font-family:var(--heading);font-weight:var(--fw-h-bold);text-transform:uppercase}.all-filters>.row.row>.label{width:20%}.all-filters>.row.row>.row>.label{white-space:nowrap}.all-filters .btn+label,.all-filters button{min-height:var(--chipchip);padding:0;width:var(--chipchip)}.all-filters .btn+label .label,.all-filters .row:has(>.btn:not(:checked)+label:hover) :checked+label .label,.all-filters button .label{bottom:-2rem;opacity:0;position:absolute;white-space:nowrap;width:-moz-max-content;width:max-content;z-index:var(--z-4)}.all-filters .btn+label:hover .label,.all-filters .btn:checked+label .label,.all-filters button:hover .label{opacity:1}.all-filters .search.row,.all-filters .view.row{display:none}.all-filters .ordering{padding:2rem 0 .75rem}.all-filters .ordering>.row label{position:unset}.all-filters .ordering .row .label{color:rgb(var(--contrast));top:-1rem}.all-filters .ordering .row.orderby .label{left:0}.all-filters .ordering .row.order-direction .label{right:0}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{padding:0;transform:scaleX(0);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base);width:0}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}.toggle-text input+label{border:1px dashed transparent;color:var(--contrast)!important;cursor:pointer;font-weight:400;padding:.25rem .5rem;position:relative;text-transform:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.toggle-text input+label:after,.toggle-text input+label:before{display:none!important}.toggle-text input+label .text{margin:0;position:relative;--gap:0;border:1px solid rgb(var(--action-50));border-radius:var(--radius);color:rgb(var(--action-50));font-weight:700;padding:2px 4px;width:-moz-fit-content;width:fit-content}.toggle-text input+label .off{--mid:-100%}.toggle-text input+label .on{--mid:100%}.toggle-text input+label .off,.toggle-text input+label .on{transition:var(--trans-transform),opacity var(--trans-base)}.toggle-text input+label .off,.toggle-text input:checked+label .on{max-width:100%;opacity:1;transform:translateZ(0)}.toggle-text input+label .on,.toggle-text input:checked+label .off{max-width:0;opacity:0;transform:translate3d(0,var(--mid),0)}.toggle-text:hover label{border-color:rgb(var(--action-200))}.toggle-text:hover .text{background-color:rgb(var(--action-50));border-color:rgb(var(--action-50));color:rgb(var(--action-contrast))}
+.feed-block{grid-column:full}.feed-block .placeholder{align-items:center;aspect-ratio:1;background:rgb(var(--base));border:1rem solid rgb(var(--base-50));border-radius:1rem;display:flex;justify-content:center;--w:50%;color:rgb(var(--base-200))}.feed-block .placeholder i.icon{animation:dance 2.5s ease-in-out infinite}.feed-block .item-grid{max-width:var(--full)}.feed-block .item-grid:has([data-timeline]){grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}.feed-block .item{background:rgb(var(--base-50));box-shadow:rgba(var(--base),var(--op-2)) var(--shdw);height:-moz-fit-content;height:fit-content;overflow:hidden;padding:0}.feed-block .item h3{font-size:var(--txt-medium);margin:0}.feed-block .item details{padding:0;position:relative;width:100%;z-index:var(--z-2)}.feed-block .item details summary{backdrop-filter:blur(5px);background-color:rgba(var(--base),var(--op-2));left:0;position:absolute;top:calc(var(--chip_)*-1);width:100%}.feed-block .item details summary:hover{background-color:rgba(var(--action-0),var(--op-45))}.feed-block .item details[open]{padding:.25rem .5rem}.feed-block .item img:hover{opacity:.8}.feed-block .item ul{margin:0}.feed-block .item[data-timeline] .images{aspect-ratio:3/2;padding:0 0 1rem}.feed-block .item[data-timeline] .images span{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast));padding:.25rem .5rem;position:absolute;width:50%}.feed-block .item[data-timeline] .images span:first-of-type{bottom:0;right:50%;text-align:right}.feed-block .item[data-timeline] .images span:last-of-type{left:50%;top:0}.feed-block .item[data-timeline] .images img{width:50%}.feed-block .item[data-timeline] .images img:first-of-type{border-right:2px solid rgb(var(--action-0))}.feed-block .item a:after,.feed-block .item a:before{display:none}.feed-block .item label{font-weight:400;text-transform:none;--w:1.5em}.feed-block .all-filters summary{display:flex;justify-content:space-between}.all-filters{font-size:var(--txt-x-small)}.all-filters[open]{border:2px solid rgb(var(--action-0));border-radius:0 0 var(--radius-outer) var(--radius-outer);padding:0}.all-filters summary{width:100%}.all-filters summary:hover,.all-filters[open] summary{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast))}.all-filters summary:hover:after,.all-filters[open] summary:after{background-color:rgb(var(--action-contrast))}.all-filters>.row.row{padding:0 .75rem 2rem;width:var(--content)}.all-filters>.row.row.search{padding-bottom:0}.all-filters>.row.row{position:relative}.all-filters>.row.row>.label,.all-filters>.row.row>.row>.label{font-family:var(--heading);font-weight:var(--fw-h-bold);text-transform:uppercase}.all-filters>.row.row>.label{width:20%}.all-filters>.row.row>.row>.label{white-space:nowrap}.all-filters .btn+label,.all-filters button{min-height:var(--chipchip);padding:0;width:var(--chipchip)}.all-filters .btn+label .label,.all-filters .row:has(>.btn:not(:checked)+label:hover) :checked+label .label,.all-filters button .label{bottom:-2rem;opacity:0;position:absolute;white-space:nowrap;width:-moz-max-content;width:max-content;z-index:var(--z-4)}.all-filters .btn+label:hover .label,.all-filters .btn:checked+label .label,.all-filters button:hover .label{opacity:1}.all-filters .search.row,.all-filters .view.row{display:none}.all-filters .ordering{padding:2rem 0 .75rem}.all-filters .ordering>.row label{position:unset}.all-filters .ordering .row .label{color:rgb(var(--contrast));top:-1rem}.all-filters .ordering .row.orderby .label{left:0}.all-filters .ordering .row.order-direction .label{right:0}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{padding:0;transform:scaleX(0);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base);width:0}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}.toggle-text input+label{border:1px dashed transparent;color:var(--contrast)!important;cursor:pointer;font-weight:400;padding:.25rem .5rem;position:relative;text-transform:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.toggle-text input+label:after,.toggle-text input+label:before{display:none!important}.toggle-text input+label .text{margin:0;position:relative;--gap:0;border:1px solid rgb(var(--action-50));border-radius:var(--radius);color:rgb(var(--action-50));font-weight:700;padding:2px 4px;width:-moz-fit-content;width:fit-content}.toggle-text input+label .off{--mid:-100%}.toggle-text input+label .on{--mid:100%}.toggle-text input+label .off,.toggle-text input+label .on{transition:var(--trans-transform),opacity var(--trans-base)}.toggle-text input+label .off,.toggle-text input:checked+label .on{max-width:100%;opacity:1;transform:translateZ(0)}.toggle-text input+label .on,.toggle-text input:checked+label .off{max-width:0;opacity:0;transform:translate3d(0,var(--mid),0)}.toggle-text:hover label{border-color:rgb(var(--action-200))}.toggle-text:hover .text{background-color:rgb(var(--action-50));border-color:rgb(var(--action-50));color:rgb(var(--action-contrast))}
diff --git a/build/feed/view.asset.php b/build/feed/view.asset.php
index 1f56951..83fa6e6 100644
--- a/build/feed/view.asset.php
+++ b/build/feed/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '7bbcf703e79934b80731');
+<?php return array('dependencies' => array(), 'version' => '7e7d1570989c0c348bd7');
diff --git a/build/feed/view.js b/build/feed/view.js
index 4ddf769..2360009 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={contextId:"",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:".all-filters",showing:".all-filters summary .current",content:'[data-filter="content"]',ordering:".ordering",orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',orderWrap:".order-direction",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),["content","orderby","order","taxonomy"].forEach(e=>{let t=this.ui.filters.container.querySelectorAll(this.selectors.filters[e]);this.ui[e]=Array.from(t)}),this.contentTypes=this.ui.content.length>0?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):[]}getChecked(e){["content","orderby","order"].includes(e)||console.log("Invalid item to check: ",e);let t=this.ui[e];if(!t)return;let i=t.filter(e=>e.checked);return"content"===e&&i.length>0&&this.updateContentFor(i[0].value),0===i.length?t[0].value:i[0].value}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.getChecked("content"),orderby:this.getChecked("orderby"),order:this.getChecked("order"),page:1};this.config.context&&(e.context=this.config.context),this.config.contextId&&(e.contextId=this.config.contextId),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){return this.selector.getFieldId(this.ui.taxonomies.filter(t=>t.dataset.taxonomy===e)[0]??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){let t=[this.ui.taxonomies,this.ui.orderby];this.ui.filters.showing.textContent=this.ui.content.filter(t=>t.value===e)[0].dataset.label,t.forEach(t=>{t&&t.forEach(t=>{const i=t.dataset.for?.split(",")??[];t.hidden=i.length>0&&!i.includes(e),t.hidden&&t.checked&&(t.checked=!1)})})}updateOrderOptions(e){if(this.ui.filters.orderWrap){let t=this.ui.filters.orderWrap.dataset.forOrder.split(",")??[];this.ui.filters.orderWrap.hidden=!t.includes(e)}}updateFilterControls(){const e=Object.keys(this.taxFilters);this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=0===e.length),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e.length<=1)}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=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.contextId}_${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.contextId}_${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.contextId}_${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.ui.orderby.filter(e=>!["date","date_modified","title","random"].includes(e.value)),t=[];e.forEach(e=>{t.push({name:e.value,keyPath:e.value})});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)=>{"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=!this.store.lastResponse?.has_more??!0))})}isFirstPage(){return 1===this.store.filters.page}renderItems(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=>{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(),this.store.lastResponse?.has_more??!1)},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){let s=i.images[t]??!1;s&&([e.src,e.srcset,e.alt]=[s.tiny,`${s.tiny} 50w, ${s.small} 300w, ${s.medium} 1024w`,s["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){let r=t.taxonomies[i][s]??!1;if(!r)continue;let a=o.cloneNode(!0),n=a.querySelector("a");if(!n)continue;let l=window.decodeHTMLEntities(r.title);[n.href,n.title,n.textContent]=[r.url,`See more ${l}`,l],e.append(a)}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=window.decodeHTMLEntities(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-1} Tx`),s&&(s.textContent=e.number-1),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=window.decodeHTMLEntities(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()}i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${r.fields.post_title??"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.isFirstLoad=!0,this.config={contextId:"",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(),this.initStore(),this.initTaxonomies().then(e=>{}),this.processCachedFilters(),this.processURLFilters(),this.updateFilterUI(),this.initGallery()}initElements(){this.selectors={filterTrigger:"[data-filter]",filters:{actions:".filter-actions .toggle-text",container:".all-filters",showing:".all-filters summary .current",content:'[data-filter="content"]',ordering:".ordering",orderby:'[data-filter="orderby"]',order:'[data-filter="order"]',orderWrap:".order-direction",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),["content","orderby","order","taxonomy"].forEach(e=>{let t=this.ui.filters.container.querySelectorAll(this.selectors.filters[e]);this.ui[e]=Array.from(t)}),this.contentTypes=this.ui.content.length>0?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):[]}getChecked(e){["content","orderby","order"].includes(e)||console.log("Invalid item to check: ",e);let t=this.ui[e];if(!t)return;let i=t.filter(e=>e.checked);return"content"===e&&i.length>0&&this.updateContentFor(i[0].value),0===i.length?t[0].value:i[0].value}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.getChecked("content"),orderby:this.getChecked("orderby"),order:this.getChecked("order"),page:1};this.config.context&&(e.context=this.config.context),this.config.contextId&&(e.contextId=this.config.contextId),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){return this.selector.getFieldId(this.ui.taxonomies.filter(t=>t.dataset.taxonomy===e)[0]??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){let t=[this.ui.taxonomies,this.ui.orderby];this.ui.filters.showing.textContent=this.ui.content.filter(t=>t.value===e)[0].dataset.label,t.forEach(t=>{t&&t.forEach(t=>{const i=t.dataset.for?.split(",")??[];t.hidden=i.length>0&&!i.includes(e),t.hidden&&t.checked&&(t.checked=!1)})})}updateOrderOptions(e){if(this.ui.filters.orderWrap){let t=this.ui.filters.orderWrap.dataset.forOrder.split(",")??[];this.ui.filters.orderWrap.hidden=!t.includes(e)}}updateFilterControls(){const e=Object.keys(this.taxFilters);this.ui.buttons.clearFilters&&(this.ui.buttons.clearFilters.hidden=0===e.length),this.ui.filters.actions&&(this.ui.filters.actions.hidden=e.length<=1)}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=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.contextId}_${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.contextId}_${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.contextId}_${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.ui.orderby.filter(e=>!["date","date_modified","title","random"].includes(e.value)),t=[];e.forEach(e=>{t.push({name:e.value,keyPath:e.value})});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"},2);this.store=i.feed,this.store.subscribe((e,t)=>{if("data-loaded"===e){if(this.isFirstLoad)return void(this.isFirstLoad=!1);this.renderItems(t.items),this.ui.buttons.loadMore.hidden=!0,this.store.lastResponse&&this.store.lastResponse?.has_more&&(this.ui.buttons.loadMore.hidden=!this.store.lastResponse?.has_more??!0)}})}isFirstPage(){return 1===this.store.filters.page}renderItems(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=>{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(),this.store.lastResponse?.has_more??!1)},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){let s=i.images[t]??!1;s&&([e.src,e.srcset,e.alt]=[s.tiny,`${s.tiny} 50w, ${s.small} 300w, ${s.medium} 1024w`,s["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){let r=t.taxonomies[i][s]??!1;if(!r)continue;let a=o.cloneNode(!0),n=a.querySelector("a");if(!n)continue;let l=window.decodeHTMLEntities(r.title);[n.href,n.title,n.textContent]=[r.url,`See more ${l}`,l],e.append(a)}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=window.decodeHTMLEntities(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.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=window.decodeHTMLEntities(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()}i.link&&""!==r.url&&(i.link.href=r.url,i.link.title=`View ${r.fields.post_title??"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/inc/blocks/FAQBlock.php b/inc/blocks/FAQBlock.php
index 90443e6..81e3db9 100644
--- a/inc/blocks/FAQBlock.php
+++ b/inc/blocks/FAQBlock.php
@@ -2,11 +2,16 @@
 namespace JVBase\blocks;
 
 use JVBase\managers\Cache;
+use JVBase\registrar\Registrar;
 use WP_Block;
 use WP_Query;
 
 class FAQBlock {
 	protected Cache $cache;
+	protected string $postType;
+	protected string $section;
+	protected string $slug;
+	protected bool $isDirectory;
 	public function __construct()
 	{
 		$this->cache = Cache::for('faq_block', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
@@ -25,6 +30,14 @@
 				'render_callback' => [$this, 'render'],
 			]
 		);
+
+
+		$faq = array_values(Registrar::getFeatured('is_faq','post'));
+		$registrar = Registrar::getInstance($faq[0]);
+		$this->section = array_map('jvbCheckBase', $registrar->registrar->taxonomies)[0];
+		$this->postType = $registrar->getBased();
+		$this->slug = $registrar->getSlug();
+		$this->isDirectory = $registrar->hasFeature('show_directory');
 	}
 
 	/**
@@ -64,12 +77,11 @@
 	public function localizeData(): void
 	{
 		// Get all sections
-		$section_taxonomy = BASE . 'section';
 		$sections_data = $this->cache->remember(
 			'sections',
 			function() {
 				$sections = get_terms([
-					'taxonomy' => BASE.'section',
+					'taxonomy' => $this->section,
 					'hide_empty' => false,
 					'orderby' => 'name',
 					'order' => 'ASC',
@@ -96,8 +108,8 @@
 			'jvb-faq-editor-script',
 			'jvbFaq',
 			[
-				'sectionTaxonomy' => $section_taxonomy,
-				'faqPostType' => BASE . 'faq',
+				'sectionTaxonomy' => $this->section,
+				'faqPostType' => $this->postType,
 				'sections' => $sections_data,
 			]
 		);
@@ -123,10 +135,6 @@
 		 * @param string   $content    Block content
 		 * @param WP_Block $block      Block instance
 		 */
-// Get BASE constant
-		$base = defined('BASE') ? BASE : '';
-		$faq_post_type = $base . 'faq';
-		$section_taxonomy = $base . 'section';
 
 // Get block attributes
 		$section_order = $attributes['sectionOrder'] ?? [];
@@ -134,7 +142,7 @@
 		$collapse_by_default = $attributes['collapseByDefault'] ?? false;
 
 // Determine if we're on a taxonomy archive or main FAQ archive
-		$is_tax_archive = is_tax($section_taxonomy);
+		$is_tax_archive = is_tax($this->section);
 		$current_term = null;
 
 		if ($is_tax_archive) {
@@ -144,7 +152,7 @@
 		} else {
 			// Build query args based on context
 			$query_args = [
-				'post_type' => $faq_post_type,
+				'post_type' => $this->postType,
 				'posts_per_page' => -1,
 				'post_status' => 'publish',
 				'orderby' => 'menu_order title',
@@ -156,10 +164,10 @@
 
 
 		if (!$faq_query->have_posts()) {
-			echo '<div class="faq-block faq-block--empty">';
-			echo '<p>' . esc_html__('No FAQs found.', 'jvb') . '</p>';
-			echo '</div>';
-			return;
+			return sprintf(
+				'<div class="faq-block empty"><p>%s</p></div>',
+				esc_html__('No FAQs found.', 'jvb')
+			);
 		}
 
 		// Organize FAQs by section
@@ -169,7 +177,7 @@
 			$faq_query->the_post();
 			$post_id = get_the_ID();
 
-			$terms = get_the_terms($post_id, $section_taxonomy);
+			$terms = get_the_terms($post_id, $this->section);
 
 			if ($terms && !is_wp_error($terms)) {
 				foreach ($terms as $term) {
@@ -245,14 +253,25 @@
 		if (!empty($section_order)) {
 			$nav = '<nav id="faq"><h2>Sections:</h2><ol>';
 			foreach ($section_order as $term_id) {
-				$term = get_term($term_id, $section_taxonomy);
+				$term = get_term($term_id, $this->section);
 				if ($term && !is_wp_error($term)) {
 					$url = (!$is_tax_archive) ? "#{$term->slug}" : get_term_link($term);
 					$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>' : '';
-			$nav .= '</ol>'.$seeAll.'</nav>';
+			$buttons = '';
+			if ($this->isDirectory) {
+				$directory = JVB()->directories()->directories($this->slug);
+				$url = $directory['url']??'';
+				if (!empty($url)) {
+					$buttons .= '<li><a href="'.$url.'">'.__('See Alphabetical List', 'jvb').'</a></li>';
+				}
+			}
+			if (is_tax()) {
+				$buttons .= '<li><a href="'.get_home_url(null, '/faq/').'">'.__('See All FAQs', 'jvb').'</a></li>';
+			}
+			$buttons = empty($buttons) ? '' : '<ul class="buttons">'.$buttons.'</ul>';
+			$nav .= '</ol>'.$buttons.'</nav>';
 		}
 		?>
 
diff --git a/inc/blocks/FeedBlock.php b/inc/blocks/FeedBlock.php
index 3693d93..abe8395 100644
--- a/inc/blocks/FeedBlock.php
+++ b/inc/blocks/FeedBlock.php
@@ -2,6 +2,7 @@
 namespace JVBase\blocks;
 
 use JVBase\managers\Cache;
+use JVBase\meta\Meta;
 use JVBase\registrar\Registrar;
 use JVBase\base\Site;
 use JVBase\forms\TaxonomySelector;
@@ -25,6 +26,9 @@
 	protected ?int $contextID = null;
 
 	protected bool $isGallery = false;
+	protected array $args;
+	protected bool $isContentTax = false;
+	protected bool $hasMore = false;
 
 	public function __construct()
 	{
@@ -33,6 +37,7 @@
 		if (JVB_TESTING) {
 			$this->cache->flush();
 		}
+		$this->cache->flush();
 
 		add_action('init', [$this, 'registerBlock']);
 	}
@@ -78,15 +83,26 @@
 	protected function determineContent(array $attrs):void
 	{
 		if (array_key_exists('inheritQuery', $attrs) && $attrs['inheritQuery'] === true) {
-			if (is_post_type_archive()) {
+			$args = [
+				'posts_per_page'	=> 36,
+				'fields'			=> 'ids',
+			];
+			if (is_post_type_archive() &&!is_tax()) {
 				$obj = get_queried_object();
+				$registrar = Registrar::getInstance($obj->name);
+				if ($registrar && $registrar->hasFeature('is_timeline')) {
+					$args['post_parent'] = 0;
+				}
 				$this->content = [jvbNoBase($obj->name)];
+				$args['post_type'] = $obj->name;
+				$this->args = $args;
 				return;
 			} elseif (!empty(Registrar::getProfileTypes()) && is_singular(Registrar::getProfileTypes())) {
 				global $post;
 				$author = $post->post_author;
 				$role = jvbUserRole($author);
 
+				$args['post_author'] = $author;
 				$this->context = jvbNoBase($role);
 				$this->contextID = $author;
 				$registrar = Registrar::getInstance($role);
@@ -94,25 +110,50 @@
 					return;
 				}
 				$this->content = $registrar->getCreatable();
+				$args['post_type'] = array_map('jvbCheckBase', $this->content);
+				foreach($args['post_type'] as $post_type) {
+					$reg = Registrar::getInstance($post_type);
+					if ($reg && $reg->hasFeature('is_timeline')) {
+						$args['post_parent'] = 0;
+					}
+				}
+				$this->args = $args;
 				return;
 			} elseif (is_tax()) {
 				$obj = get_queried_object();
 				$this->context = jvbNoBase($obj->taxonomy);
 				$this->contextID = $obj->term_id;
+				$args['tax_query'] = [];
+				$args['tax_query'][] = [
+					'taxonomy'	=> $obj->taxonomy,
+					'terms'		=> $obj->term_id,
+				];
 				$registrar = Registrar::getInstance($obj->taxonomy);
 				if (!$registrar) {
 					return;
 				}
 				if ($registrar->hasFeature('is_content')) {
-					//example: tattoo shop, etc TODO
+					$this->isContentTax = true;
+					$this->content = [$registrar->getBased()];
 					return;
 				}
 				$this->content = array_map(function ($item) { return jvbNoBase($item); }, $registrar->registrar->for);
+				$args['post_type'] = array_map('jvbCheckBase', $registrar->registrar->for);
+				foreach($args['post_type'] as $post_type) {
+					$reg = Registrar::getInstance($post_type);
+					if ($reg && $reg->hasFeature('is_timeline')) {
+						$args['post_parent'] = 0;
+					}
+				}
+				$this->args = $args;
 				return;
 			}
 		}
 		// not inheriting, getting from config
 		$this->content = $attrs['contentTypes']??[];
+
+		$args['post_type'] = array_map('jvbCheckBase', $attrs['contentTypes']);
+		$this->args = $args;
 	}
 		protected function getContent():string
 		{
@@ -212,7 +253,6 @@
 					'fields'	=> 'ids',
 				];
 				if (!is_null($this->context)) {
-
 					$context = Registrar::getInstance($this->context);
 					switch ($context->getType()) {
 						case 'term':
@@ -229,6 +269,7 @@
 				}
 				$check = new WP_Query($args);
 				$hasPosts = !empty($check->posts);
+				wp_reset_postdata();
 
 				$disabled = !$hasPosts;
 				$checked = $i === 0 && $hasPosts;
@@ -463,7 +504,7 @@
 			);
 		}
 
-	protected function renderGrid():string
+	protected function renderPlaceholders():string
 	{
 		$placeholders = '';
 		$total = count($this->content) - 1;
@@ -492,13 +533,58 @@
 		);
 	}
 
+
+	protected function renderGrid():string
+	{
+		if (empty($this->args)) {
+			return $this->renderPlaceholders();
+		}
+		$items = $this->getItems();
+
+		$out = '<div class="item-grid">';
+			$out .= implode('',array_map(function ($ID) {
+				$content = $this->isContentTax
+					? jvbNoBase($this->content[0])
+					: jvbNoBase(get_post_type($ID));
+
+				return $this->renderItem($content, $ID);
+			}, $items));
+		$out .= '</div>';
+		return $out;
+	}
+		protected function getItems():array
+		{
+			if ($this->isContentTax) {
+				$items = get_terms([
+					'taxonomy'	=> $this->content,
+					'fields'	=> 'ids',
+					'meta_key'	=> BASE.'date_modified',
+					'meta_type'	=> 'DATETIME',
+					'orderby'	=> 'meta_value',
+					'order'		=> 'desc',
+					'number'	=> $this->args['posts_per_page']
+				]);
+				$items = $items && !is_wp_error($items) ? $items : [];
+			} else {
+				$items = new WP_Query($this->args);
+				$this->hasMore = $items->found_posts > $this->args['posts_per_page'];
+				$items = $items->posts;
+				wp_reset_postdata();
+			}
+			return $items;
+		}
+
 	protected function renderLoader():string
 	{
-		return sprintf(
-			'<button type="button" class="load-more">%s<span>Show Me More</span>%s</button>
-			%s%s',
+		$button = sprintf(
+			'<button type="button" class="load-more"%s>%s<span>Show Me More</span>%s</button>',
+			$this->hasMore ? '' : ' hidden',
 			jvbIcon('arrow-elbow-left-down'),
 			jvbIcon('arrow-elbow-right-down'),
+		);
+		return sprintf(
+			'%s%s%s',
+			$button,
 			jvbLoadingScreen(),
 			$this->isGallery ? jvbRenderGallery(false) : '',
 		);
@@ -542,10 +628,10 @@
 
 	protected function renderActions():string
 	{
-		return sprintf(
+		return is_user_logged_in() ? sprintf(
 			'<button data-action="refresh" data-ignore>%s<span>Hard Refresh</span></span></button>',
 			jvbIcon('arrows-clockwise')
-		);
+		) : '';
 	}
 
 	public static function getFavouritesButton(string $content):string
@@ -554,10 +640,11 @@
 		if (!$registrar || !Site::has('favourites') || !$registrar->hasFeature('favouritable')) {
 			return '';
 		}
-		return '<button class="favourite" type="button" title="Add to favourites" data-action="favourite">
-			'.jvbIcon('heart')
-			.jvbIcon('heart', ['style'=>'fill']).'
-		</button>';
+		return sprintf(
+			'<button class="favourite" type="button" title="Add to favourites" data-action="favourite">%s%s</button>',
+			jvbIcon('heart'),
+			jvbIcon('heart', ['style'=>'fill'])
+		);
 	}
 	public static function getUpvotesButton(string $content):string
 	{
@@ -565,98 +652,239 @@
 		if (!Site::has('karma') || !$registrar || !$registrar->hasFeature('karma')){
 			return '';
 		}
-		return '<div class="karma row">
+		return sprintf(
+			'<div class="karma row">
 			<button type="button" class="vote" data-action="upvote">
-				'.jvbIcon('arrow-fat-up')
-				.jvbIcon('arrow-fat-up', ['style'=>'fill']).
-			'</button>
+				%s%s
+			</button>
 			<button type="button" class="vote" data-action="downvote">
-				'.jvbIcon('arrow-fat-down')
-				.jvbIcon('arrow-fat-down', ['style'=>'fill']).
-			'</button>
+				%s%s
+			</button>
 			<span class="score"></span>
-		</div>';
+		</div>',
+			jvbIcon('arrow-fat-up'),
+			jvbIcon('arrow-fat-up', ['style'=>'fill']),
+			jvbIcon('arrow-fat-down'),
+			jvbIcon('arrow-fat-down', ['style'=>'fill'])
+		);
 	}
 	protected function getDefaultTemplate(string $content): string
 	{
-		$template = apply_filters('jvbFeedItem', '', $content);
-		if (empty($template)) {
-			$config = Registrar::getInstance($content)->getConfig('feed');
-			$allFields = Registrar::getFieldsFor($content);
-			$images = $config['images']??['post_thumbnail'];
-			$fields = $config['fields']??['post_title','post_date','post_excerpt'];
-			$fields = array_filter($fields, function($field) use($images) {
-				return !in_array($field, $images);
-			});
-			$fields = array_filter($allFields, function($field) use($fields) {
-				return in_array($field, $fields);
-			}, ARRAY_FILTER_USE_KEY);
-
-
-			$template = sprintf(
-				'<div class="feed item col %s">%s%s',
-				$content,
-				self::getFavouritesButton($content),
-				self::getUpvotesButton($content)
-			);
-
-			//Add all defined images, but allow for filtering
-			$imageTemplate = '<a>';
-			foreach ($images as $image) {
-				$imageTemplate .= sprintf(
-					'<img data-field="%s" width="300px" height="300px" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px" loading="lazy" decoding="async">',
-					$image
-				);
-			}
-			$imageTemplate .= '</a>';
-
-			$template .= sprintf(
-				'<div class="images">%s</div>',
-				apply_filters('jvbFeedImages', $imageTemplate, $content, $images)
-			);
-
-			//Output default fields, but allow for filtering
-			$template .= sprintf(
-				'<details>
-		<summary>%s</summary>',
-				apply_filters('jvbFeedItemSummary', jvbIcon('dots-three'), $content)
-			);
-
-			$fieldsTemplate = '';
-			foreach ($fields as $fieldName => $config) {
-				$fieldsTemplate .= apply_filters('jvbFeedItemField', $this->defaultFieldTemplate($config['type'], $fieldName), $content, $fieldName, $config['type']);
-			}
-			$template .= sprintf(
-				'<div class="item-info">%s</div>',
-				apply_filters('jvbFeedItemFields', $fieldsTemplate, $content, $fields)
-			);
-			$template .= '</details></div>';
-		}
-
-
 		return sprintf(
 			'<template class="feedItem%s">%s</template>',
 			ucfirst($content),
-			$template
+			$this->renderItem($content)
 		);
 	}
 
-	protected function defaultFieldTemplate(string $fieldType, string $fieldName):string
+	protected function renderItem(string $content, ?int $ID = null):string
+	{
+		/**
+		 * Allow plugins to replace the content within the feed item
+		 */
+		$function = BASE.'render_'.$content.'_feed_item';
+		if (function_exists($function)) {
+			$out = $function($ID);
+		} else {
+			$out = $this->buildFeedItem($content, $ID);
+		}
+		$registrar = Registrar::getInstance($content);
+		$data = [];
+		if ($registrar && $registrar->hasFeature('is_timeline')) {
+			$data[] = 'data-timeline';
+		}
+
+		$data = !empty($data) ? ' '.implode(' ', $data) : '';
+
+		return sprintf(
+			'<div class="feed item col %s"%s>%s%s%s</div>',
+			$content,
+			$data,
+			self::getFavouritesButton($content),
+			self::getUpvotesButton($content),
+			$out
+		);
+	}
+		protected function buildFeedItem(string $content, ?int $ID = null):string
+		{
+			$registrar = Registrar::getInstance($content);
+			$meta = is_null($ID) ? false :
+				match ($registrar->getType()) {
+					'post'	=> Meta::forPost($ID),
+					'term'	=> Meta::forTerm($ID),
+					'user'	=> Meta::forUser($ID),
+					default => false
+				};
+
+			[$images, $fields] = $registrar->getFeedFields();
+
+			/**
+			 * Get the main image for the feed item.
+			 * Output can be overridden with the $imagesFn
+			 */
+			$imagesFn = BASE.'render_'.$content.'_feed_item_images';
+			if (function_exists($imagesFn)) {
+				$img = $imagesFn($ID, $images);
+			} else {
+				$img = '';
+				foreach ($images as $config) {
+					$field = $config['name'];
+					$img .= $meta ? jvbFormatImage($meta->get($field), 'tiny', 'medium')
+						: $this->defaultFieldTemplate($config['type'], $field);
+				}
+			}
+			$img = sprintf(
+				'<div class="images"><a href="%s">%s</a></div>',
+				$ID ? get_the_permalink($ID) : '',
+				$img
+			);
+
+			/**
+			 * Start the Details with the fields
+			 * Plugins can modify the summary title with the 'jvbFeedItemSummary' filter
+			 */
+			$summary = sprintf(
+				'<details><summary>%s</summary>',
+				apply_filters('jvbFeedItemSummary', jvbIcon('dots-three'), $content)
+			);
+				/**
+				 * Work through the fields
+				 * Each field output can be overridden with $field or $fieldType
+				 */
+				foreach ($fields as $config) {
+					$f = $config['name'];
+					$functions = [
+						BASE.'render_'.$content.'_field_'.$f, 	//Overrides field for this content
+						BASE.'render_field_'.$f,				//Overrides field with this name
+						BASE.'render_field_type_'.$config['type'] //Overrides field of this type
+					];
+
+					$didIt = false;
+					foreach ($functions as $func) {
+						if (!$didIt && function_exists($func)) {
+							$didIt = true;
+							$summary .= $func($ID, is_null($ID));
+						}
+					}
+					if (!$didIt) {
+						$summary .= $this->defaultFieldTemplate($config['type'], $f, is_null($ID), $meta, $config);
+					}
+
+				}
+			$summary .= '</details>';
+
+			return $img.$summary;
+		}
+
+	protected function defaultFieldTemplate(string $fieldType, string $fieldName, bool $isTemplate = true, bool|Meta $meta = false, array $config = []):string
 	{
 		$data = ' data-field="'.$fieldName.'"';
+		$value = $meta ? $meta->get($fieldName) : '';
+		if (!$isTemplate && empty($value)) {
+			return '';
+		}
 		switch ($fieldName) {
 			case 'post_title':
-				return '<h3'.$data.'></h3>';
+				return sprintf(
+					'<h3%s>%s</h3>',
+			$data,
+					$meta && !empty($value) ? $value : '',
+				);
 			case 'post_date':
 			case 'post_modified':
-				return '<time'.$data.'></time>';
+				return sprintf(
+					'<time%s%s>%s</time>',
+					$data,
+					$meta && !empty($value) ? date('c', strtotime($value)) : '',
+					$meta && !empty($value) ? date('F j, Y', strtotime($value)) : '',
+				);
 		}
-		return match($fieldType) {
-			'date','datetime','time' => '<time'.$data.'></time>',
-			'upload'	=> '<img'.$data.' width="300px" height="300px" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px" loading="lazy" decoding="async">',
-			'taxonomy'	=> '<ul'.$data.'><li><a><i></i></a></li></ul>',
-			default	=>  '<p'.$data.'></p>',
+
+		return match ($fieldType) {
+			'date' => sprintf(
+				'<time%s%s>%s</time>',
+				$data,
+				$meta && !empty($value) ? date('c', strtotime($value)) : '',
+				$meta && !empty($value) ? date('F j, Y', strtotime($value)) : '',
+			),
+			'datetime' => sprintf(
+				'<time%s%s>%s</time>',
+				$data,
+				$meta && !empty($value) ? date('c', strtotime($value)) : '',
+				$meta && !empty($value) ? date('F j, Y at h:iA', strtotime($value)) : '',
+			),
+			'time' => sprintf(
+				'<time%s%s>%s</time>',
+				$data,
+				$value,
+				$value
+			),
+			'upload' => empty($value) ? sprintf(
+				'<img%s width="300px" height="300px" loading="lazy" decoding="async">',
+				$data
+			) :
+				str_replace('<img', '<img' . $data, jvbFormatImage($value)),
+			'selector' => sprintf(
+				'<ul%s class="terms %s">%s</ul>',
+				$data,
+				$config['taxonomy']??$config['post_type']??$config['role']??'',
+				empty($value) ? sprintf('<li><a>%s</a></li>',
+					$this->iconFor($config)
+				) :
+					implode('', array_filter(array_map(function ($ID) use ($config) {
+						$type = $config['subtype'];
+						$taxonomy = isset($config['taxonomy']) ? jvbCheckBase($config['taxonomy']) : '';
+
+						$item = match ($type) {
+							'taxonomy' => get_term($ID, $taxonomy),
+							'user' => get_userdata($ID),
+							'post' => get_post($ID),
+							default => false,
+						};
+
+						if (!$item || is_wp_error($item)) {
+							return '';
+						}
+
+						$icon = $this->iconFor($config);
+
+						$name = match ($type) {
+							'taxonomy' => $item->name,
+							'user' => $item->display_name,
+							'post' => $item->post_title,
+							default => '',
+						};
+						$url = match ($type) {
+							'taxonomy' => get_term_link((int)$ID, $taxonomy),
+							'user' => get_the_permalink(get_user_meta($ID, BASE . 'userLink', true)),
+							'post' => get_the_permalink($ID),
+							default => ''
+						};
+						return sprintf(
+							'<li><a href="%s">%s%s</a></li>',
+							$url,
+							!empty($icon) ? jvbIcon($icon) : '',
+							$name
+						);
+					}, explode(',', $value))))
+			),
+			default => sprintf(
+				'<p%s>%s</p>',
+				$data,
+				$value
+			),
 		};
 	}
+	protected function iconFor(array $fieldConfig):string
+	{
+		$icon = match ($fieldConfig['type']??'') {
+			'taxonomy' => Registrar::getInstance($fieldConfig['taxonomy'])->getIcon(),
+			'user' => isset($fieldConfig['role']) ? Registrar::getInstance($fieldConfig['role'])->getIcon() : 'user',
+			'post' => Registrar::getInstance($fieldConfig['post_type'])->getIcon(),
+			default => ''
+		};
+
+		return empty($icon) ? $icon : jvbIcon($icon);
+	}
 
 }
diff --git a/inc/blocks/TimelineBlock.php b/inc/blocks/TimelineBlock.php
index 3f2666c..85b9add 100644
--- a/inc/blocks/TimelineBlock.php
+++ b/inc/blocks/TimelineBlock.php
@@ -59,13 +59,7 @@
         }
 		$this->content = jvbNoBase($post->post_type);
 
-		$this->children = get_children([
-			'post_parent' 	=> $this->parentID,
-			'post_status'	=> 'publish',
-			'orderby' 		=> 'date',
-			'order' 		=> 'ASC',
-			'fields' 		=> 'ids'
-		]);
+		$this->children = jvbTimelinePoints($this->parentID,$post->post_type);
 		$this->total = count($this->children);
 
         ob_start();
diff --git a/inc/helpers/all.php b/inc/helpers/all.php
index 67d79fe..0891fab 100644
--- a/inc/helpers/all.php
+++ b/inc/helpers/all.php
@@ -72,3 +72,17 @@
 		'message'	=> is_null($msg) ? ($success ? 'Completed successfully' : 'Something went wrong') : $msg
 	];
 }
+
+function jvbTimelinePoints(int $ID, string $type, array $status = ['publish']):array
+{
+	$type = jvbCheckBase($type);
+	return get_children([
+		'post_parent'	=> $ID,
+		'orderby'		=> 'date',
+		'order'			=> 'ASC',
+		'posts_per_page'=> -1,
+		'post_status'	=> $status,
+		'fields'		=> 'ids',
+		'post_type'		=> $type
+	]);
+}
diff --git a/inc/managers/Cache.php b/inc/managers/Cache.php
index 7653b9a..c144013 100644
--- a/inc/managers/Cache.php
+++ b/inc/managers/Cache.php
@@ -18,6 +18,8 @@
 
 	private static array $instances = [];
 	private bool $hasRedis;
+	private bool $varyByAuth = false;
+	private bool $varyByUser = false;
 
 	private function __construct(string $group, int $ttl)
 	{
@@ -58,6 +60,29 @@
 		add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 2);
 	}
 
+	public function auth(): static
+	{
+		$this->varyByAuth = true;
+		return $this;
+	}
+
+	public function user(): static
+	{
+		$this->varyByUser = true;
+		return $this;
+	}
+
+	private function resolveKey(int|string $key): string
+	{
+		if ($this->varyByUser) {
+			return $key . ':u' . get_current_user_id();
+		}
+		if ($this->varyByAuth) {
+			return $key . ':' . (is_user_logged_in() ? 'auth' : 'guest');
+		}
+		return (string) $key;
+	}
+
 	/* ---------------------------------------------------------------------
 	 * Factory
 	 * ------------------------------------------------------------------- */
@@ -358,12 +383,13 @@
 		?int $ttl = null
 	): mixed {
 		if (is_array($key)) {
-			$id = $this->generateKey($key);
+			$key = $this->generateKey($key);
 		}
+		$instanceTags = array_map(fn($tag) => [$this->group, $tag], $this->getTags());
 		$tags = array_unique(array_merge(
-			$this->getTags(),
-			array_map('sanitize_key', $tags)
-		));
+			$instanceTags,
+			array_map(fn($tag) => [sanitize_key($tag[0]), $tag[1]], $tags)
+		), SORT_REGULAR);
 
 
 		$value = wp_cache_get($key, $this->group);
diff --git a/inc/managers/CustomTable.php b/inc/managers/CustomTable.php
index cb458ce..572d8a2 100644
--- a/inc/managers/CustomTable.php
+++ b/inc/managers/CustomTable.php
@@ -49,15 +49,16 @@
 	 *
 	 * @example CustomTable::for('favourites')->insert($data);
 	 */
-	public static function for(string $tableName): self
+	public static function for(string $tableName, bool $user = false, bool $auth = false, bool $useTransactions = false): self
 	{
 		if (!isset(self::$instances[$tableName])) {
-			self::$instances[$tableName] = new self($tableName);
+			self::$instances[$tableName] = new self($tableName, $user, $auth, $useTransactions);
 		}
 
 
 		return self::$instances[$tableName];
 	}
+
 	public static function destroyInstance(string $tableName):void
 	{
 		if (isset(self::$instances[$tableName])) {
@@ -260,7 +261,7 @@
 	 * @param string $tableName Table name without prefix/BASE (e.g., 'favourites', 'notifications')
 	 * @param bool $useTransactions Whether to auto-wrap operations in transactions
 	 */
-	public function __construct(string $tableName, bool $useTransactions = false)
+	public function __construct(string $tableName, bool $user = false, bool $auth = false, bool $useTransactions = false)
 	{
 		global $wpdb;
 		$this->wpdb = $wpdb;
@@ -269,6 +270,12 @@
 		$this->useTransactions = $useTransactions;
 
 		$this->cache = Cache::for($tableName);
+		if ($user) {
+			$this->cache->user();
+		}
+		if ($auth) {
+			$this->cache->auth();
+		}
 
 		$usersStatus = $this->wpdb->get_row("SHOW TABLE STATUS LIKE '{$this->wpdb->users}'");
 		$parentCollation = $usersStatus->Collation ?? 'utf8mb4_general_ci';
diff --git a/inc/managers/DashboardManager.php b/inc/managers/DashboardManager.php
index 26b724f..395e6c2 100644
--- a/inc/managers/DashboardManager.php
+++ b/inc/managers/DashboardManager.php
@@ -25,11 +25,11 @@
 
     public function __construct()
     {
-        $this->cache = Cache::for('dashboard', WEEK_IN_SECONDS)->connect('user');
+        $this->cache = Cache::for('dashboard', WEEK_IN_SECONDS)->connect('user')->user();
 		if (JVB_TESTING) {
 			$this->cache->flush();
 		}
-		$this->cache->flush();
+
         add_action('init', [$this, 'registerDashboard']);
 
         $this->user = wp_get_current_user();
@@ -45,11 +45,12 @@
 
 		jvb_register_do_once('buildDashboard', [$this, 'activate']);
 
-		add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeDashboard'], 10, 1);
+		add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeDashboard'], 8, 1);
     }
 
-	public function excludeDashboard(array $ids):array {
-		$cached = $this->cache->remember(
+	public function excludeDashboard(array $IDs):array {
+		$this->cache->flush();
+		$exclude = $this->cache->remember(
 			'dashboardIDs',
 			function() {
 				return get_posts([
@@ -58,7 +59,11 @@
 					'fields' => 'ids',
 				]);
 			});
-		return array_merge($ids, $cached);
+		if (!empty($exclude)) {
+			$IDs = array_merge($IDs, $exclude);
+		}
+
+		return $IDs;
 	}
 
     /**
diff --git a/inc/managers/DirectoryManager.php b/inc/managers/DirectoryManager.php
index a72afb8..4645f97 100644
--- a/inc/managers/DirectoryManager.php
+++ b/inc/managers/DirectoryManager.php
@@ -325,7 +325,7 @@
         string $name = '',
         string $url = '',
         string $ID = '',
-        $extra = false
+        ?array $extra = null
     ):array {
         if ($name == '') {
             $name = get_the_title();
@@ -438,12 +438,20 @@
 	return $this->cache->remember(
 		$cacheKey,
 		function() use ($slug, $type, $registrar, $config, $paged) {
-			$out = '<h1>' . $this->directoryTitle($registrar) . '</h1>';
+
+			$out = '<h1>' . $config['title'] . '</h1>';
 			$out .= '<div class="description">';
 			foreach ($config['description'] ?? [] as $p) {
 				$out .= '<p>' . $p . '</p>';
 			}
 			$out .= '</div>';
+			$link = match($registrar->getType()) {
+				'post'	=> get_post_type_archive_link($registrar->getBased()),
+				'term'	=> false,
+				'user'	=> get_post_type_archive_link($registrar->getProfile()->getBased()),
+				default => false,
+			};
+			$out .= $link ? '<ul class="buttons"><li><a href="'.$link.'">See All</a></li></ul>' : '';
 			$out .= $this->renderIndex($slug);
 
 			$list = [];
@@ -466,7 +474,7 @@
 
 					$get = new WP_Query($args);
 
-						$hasExtra = $registrar->hasFeature('directory_extra');
+						$hasExtra = !empty($config['directory_extra']??[]);
 						if ($get->have_posts()) {
 							while ( $get->have_posts() ) {
 								$get->the_post();
@@ -673,12 +681,13 @@
             foreach ($items as $item) {
                 $extra = '';
                 if (!empty($item['extra'])) {
-                    $extra = '<span>';
+                    $extra = '<ul class="term-list row">';
                     foreach ($item['extra'] as $ext) {
+						$icon = Registrar::getInstance($ext['type'])->getIcon();
                         $umamiType = ($ext['type'] === BASE.'shop') ? 'click_shop' : 'click_taxonomy';
-                        $extra .= '<a href="'.$ext['url'].'"'.$umami->trackContentClick($item['id'],$umamiType, ['source_type' => 'directory']).'>'.$ext['name'].'</a>';
+                        $extra .= '<li><a href="'.$ext['url'].'"'.$umami->trackContentClick($item['id'],$umamiType, ['source_type' => 'directory']).'>'.jvbIcon($icon).$ext['name'].'</a></li>';
                     }
-                    $extra .= '</span>';
+                    $extra .= '</ul>';
                 }
 
 				$item_html = apply_filters('jvb_directory_render_item', '', $item, $type, $extra);
diff --git a/inc/managers/FavouritesManager.php b/inc/managers/FavouritesManager.php
index 563dde2..0ea911c 100644
--- a/inc/managers/FavouritesManager.php
+++ b/inc/managers/FavouritesManager.php
@@ -29,7 +29,7 @@
 	}
 		private function defineFavouriteTable():void
 		{
-			$table = CustomTable::for('favourites');
+			$table = CustomTable::for('favourites', true);
 
 			$table->setColumns([
 				'id'		=> 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
diff --git a/inc/managers/InvitationsManager.php b/inc/managers/InvitationsManager.php
index ccb2f2f..c8d17a2 100644
--- a/inc/managers/InvitationsManager.php
+++ b/inc/managers/InvitationsManager.php
@@ -25,7 +25,7 @@
 			return;
 		}
 		$this->setInviteConfig();
-		$this->cache = Cache::for('invitations');
+		$this->cache = Cache::for('invitations')->user();
 		add_action('init', [$this, 'registerInvitationExecutors'], 5);
 
 		add_action('user_register', [$this, 'checkInvitation']);
diff --git a/inc/managers/KarmaManager.php b/inc/managers/KarmaManager.php
index 4aaa56e..7b4cccc 100644
--- a/inc/managers/KarmaManager.php
+++ b/inc/managers/KarmaManager.php
@@ -60,7 +60,7 @@
 //				break;
 //		}
 		if (!isset(self::$cache)) {
-			self::$cache = Cache::for('user_karma')->connect('user');
+			self::$cache = Cache::for('user_karma')->connect('user')->user();
 		}
 
 		add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
diff --git a/inc/managers/LoginManager.php b/inc/managers/LoginManager.php
index 68db551..39930db 100644
--- a/inc/managers/LoginManager.php
+++ b/inc/managers/LoginManager.php
@@ -66,7 +66,7 @@
 		// Allow other features to register handlers
 		do_action('jvbLoginManagerInit', $this);
 		add_action('user_register', array($this, 'saveRegistrationFields'), 999, 2);
-		add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeLoginSitemap'], 10, 1);
+		add_filter('the_seo_framework_sitemap_exclude_ids', [$this, 'excludeLoginSitemap'], 8, 1);
 	}
 	public static function getInstance():self
 	{
@@ -75,7 +75,10 @@
 
 	public function excludeLoginSitemap(array $ids): array
 	{
-		$ids[] = $this->getLoginPage();
+		$loginPage = $this->getLoginPage();
+		if (!empty($loginPage)) {
+			$ids = array_merge($ids, [$loginPage]);
+		}
 		return $ids;
 	}
 	/**************************************************************************
diff --git a/inc/managers/Notifications/EmailDigests.php b/inc/managers/Notifications/EmailDigests.php
index 978ff7f..8ab699e 100644
--- a/inc/managers/Notifications/EmailDigests.php
+++ b/inc/managers/Notifications/EmailDigests.php
@@ -18,8 +18,6 @@
 class EmailDigests
 {
 	protected string $campaign;
-	protected Cache $terms;
-	protected Cache $users;
 	protected CustomTable $userIndex;
 	protected CustomTable $termIndex;
 	public function __construct()
diff --git a/inc/managers/OperationQueue.php b/inc/managers/OperationQueue.php
index 3b93ccf..7ea6aaa 100644
--- a/inc/managers/OperationQueue.php
+++ b/inc/managers/OperationQueue.php
@@ -59,7 +59,7 @@
     {
 		global $wpdb;
 		$this->wpdb = $wpdb;
-		$this->cache = Cache::for('queue', DAY_IN_SECONDS)->connect('user');
+		$this->cache = Cache::for('queue', DAY_IN_SECONDS)->connect('user')->user();
         add_action('jvb_process_queue', [ $this, 'checkQueue' ]);
 		add_action('jvb_queue_maintenance', [$this, 'hourlyMaintenance']);
         add_action('jvbEmailDailyMetricsReport', [$this, 'emailDailyMetricsReport']);
diff --git a/inc/managers/ReferralManager.php b/inc/managers/ReferralManager.php
index 55f95a6..fb58b94 100644
--- a/inc/managers/ReferralManager.php
+++ b/inc/managers/ReferralManager.php
@@ -55,11 +55,11 @@
 		$this->default_settings['referral_role'] = $this->role;
 
 		$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->requestCache = Cache::for('referral_requests', WEEK_IN_SECONDS)->connect('referrals', true);
+		$this->statsCache = Cache::for('referral_stats', WEEK_IN_SECONDS)->connect('referrals', true)->user();
 		if (JVB_TESTING) {
 			$this->cache->flush();
-			$this->requestCache->flush();
+//			$this->requestCache->flush();
 			$this->statsCache->flush();
 		}
 
diff --git a/inc/managers/SEO/BreadcrumbManager.php b/inc/managers/SEO/BreadcrumbManager.php
index 633ade4..41f82ac 100644
--- a/inc/managers/SEO/BreadcrumbManager.php
+++ b/inc/managers/SEO/BreadcrumbManager.php
@@ -56,14 +56,14 @@
 			case is_singular():
 				$key = get_queried_object_id();
 				break;
-			case is_post_type_archive():
-				$obj = get_queried_object();
-				$key = $obj->name;
-				break;
 			case is_tax():
 				$obj = get_queried_object();
 				$key = $obj->taxonomy;
 				break;
+			case is_post_type_archive():
+				$obj = get_queried_object();
+				$key = $obj->name;
+				break;
 			case is_home():
 				$obj = get_queried_object();
 				$key = $obj->post_type;
diff --git a/inc/managers/ScriptLoader.php b/inc/managers/ScriptLoader.php
index 22a52b4..b8fa823 100644
--- a/inc/managers/ScriptLoader.php
+++ b/inc/managers/ScriptLoader.php
@@ -1,8 +1,11 @@
 <?php
+
+use JVBase\rest\routes\LoginRoutes;
+
 add_action('init', 'jvbRegisterScripts', 5);
 
 function jvbRegisterScripts() {
-	$version = '1.1.6';
+	$version = '1.1.66';
 	$strategy = [
 		'strategy'	=> 'defer',
 		'in_footer'	=> true
@@ -45,6 +48,7 @@
 		$strategy
 	);
 	wp_localize_script('jvb-auth', 'jvbBase', ['base' => BASE]);
+	wp_localize_script('jvb-auth', 'jvbAuth', LoginRoutes::auth());
 	wp_register_script(
 		'jvb-interactions',
 		JVB_URL.'assets/js/min/interactions.min.js',
diff --git a/inc/managers/UserTermsManager.php b/inc/managers/UserTermsManager.php
index cf8d595..ffc982e 100644
--- a/inc/managers/UserTermsManager.php
+++ b/inc/managers/UserTermsManager.php
@@ -12,14 +12,12 @@
 }
 class UserTermsManager
 {
-    private Cache $cache;
 
 	protected CustomTable $index;
 
     public function __construct()
     {
 		$this->defineTables();
-		$this->cache = Cache::for('term_ids')->connect('user');
 
         // Register hooks
         add_action('set_object_terms', [$this, 'handleTermAssignment'], 10, 6);
diff --git a/inc/managers/queue/Storage.php b/inc/managers/queue/Storage.php
index 2a5d540..68b8d19 100644
--- a/inc/managers/queue/Storage.php
+++ b/inc/managers/queue/Storage.php
@@ -19,7 +19,7 @@
 	public function __construct()
 	{
 		$this->defineTables();
-		$this->cache = Cache::for('queue', DAY_IN_SECONDS);
+		$this->cache = Cache::for('queue', DAY_IN_SECONDS)->user();
 	}
 
 	public function hasProcessingOperations(): bool
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
index 55835f0..6b6aa1a 100644
--- a/inc/managers/queue/executors/ContentExecutor.php
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -249,12 +249,7 @@
 			return [];
 		}
 
-		$children = get_children([
-			'post_parent' => $parentID,
-			'posts_per_page' => -1,
-			'post_status' => ['publish', 'draft'],
-		]);
-
+		$children = jvbTimelinePoints($parentID, $parent->post_type, ['publish', 'draft']);
 
 		if (count($children) === 0) {
 			return [];
@@ -465,11 +460,7 @@
 			$meta = Meta::forPost($parentID);
 			$values = $meta->getAll($shared);
 
-			$children = get_children([
-				'post_parent' => $parentID,
-				'posts_per_page' => -1,
-				'fields' => 'ids',
-			]);
+			$children = jvbTimelinePoints($parentID, get_post_type($parentID), ['any']);
 
 			if (empty($children)) {
 				continue;
@@ -488,11 +479,8 @@
 		});
 
 		foreach ($updates as $parentID => $status) {
-			$children = get_children([
-				'post_parent'	=> $parentID,
-				'posts_per_page' => -1,
-				'fields'	=> 'ids'
-			]);
+			$children = jvbTimelinePoints($parentID, get_post_type($parentID), ['any']);
+
 			if (!empty($children)) {
 				foreach($children as $child) {
 					if ($status === 'trash') {
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
index 5f244d6..ac659a4 100644
--- a/inc/managers/queue/executors/UploadExecutor.php
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -329,6 +329,7 @@
 				$postID = wp_get_post_parent_id($attachmentId);
 				if ($postID && !in_array($postID, $postsAttachedTo)){
 					$postsAttachedTo[] = $postID;
+					//TODO: is there a better way?
 				}
 			}
 
@@ -681,14 +682,7 @@
 	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'
-		]);
+		$children = jvbTimelinePoints($parentId, get_post_type($parentId), ['any']);
 
 		// Count includes parent + children
 		$number = count($children) + 1;
diff --git a/inc/registrar/Fields.php b/inc/registrar/Fields.php
index c2688ae..522b5d1 100644
--- a/inc/registrar/Fields.php
+++ b/inc/registrar/Fields.php
@@ -107,6 +107,11 @@
 				'type'	=> 'datetime',
 				'label'	=> 'Date',
 			],
+			'post_modified' => [
+				'type'	=> 'datetime',
+				'label'	=> 'Date Modified',
+				'hidden'	=> true,
+			],
 			'post_content' => [
 				'type'	=> 'textarea',
 				'quill'	=> true,
diff --git a/inc/registrar/Registrar.php b/inc/registrar/Registrar.php
index c4f0989..88668ec 100644
--- a/inc/registrar/Registrar.php
+++ b/inc/registrar/Registrar.php
@@ -49,6 +49,8 @@
 	public ?string $rewrite_taxonomy = null;
 
 	public bool $add_image_column = false;
+	public bool $prefix_post_type = false;
+	public string $prefix_with = 'by';
 
 	protected static array $allFlags = [
 		//Shared Flags
@@ -56,7 +58,7 @@
 		//Post Flags
 		'hide_single', 'redirect_to_author', 'is_calendar', 'single_image', 'is_timeline', 'is_gallery', 'is_faq', 'is_glossary', 'rewrite_taxonomy', 'add_image_column',
 		//Taxonomy Flags
-		'is_content', 'is_ownable', 'verify_entry', 'track_changes', 'associate_user_content',
+		'is_content', 'is_ownable', 'verify_entry', 'track_changes', 'associate_user_content', 'prefix_post_type',
 		//User Flags
 		'has_dashboard', 'can_register', 'can_create', 'keep_stats', 'can_favourite', 'member_verified', 'profile_link', 'manage_others'
 	];
@@ -236,6 +238,59 @@
 		add_filter('jvbDashboardPage', [$this, 'renderDashPage'], 10, 3);
 	}
 
+	public static function maybeExcludeSingles(array $IDs):array
+	{
+		self::ensureInstanced();
+
+		$features = ['hide_single', 'is_timeline'];
+		foreach ($features as $feature) {
+			foreach (self::getFeatured($feature) as $instance) {
+				$instance = self::getInstance($instance);
+				$cache = Cache::for('tsf')->connect($instance->getType());
+				$cache->flush();
+
+				$exclude = $cache->remember(
+					$feature,
+					function () use ($instance, $feature) {
+						switch ($feature) {
+							case 'hide_single':
+								return $instance->excludeSingle();
+							case 'is_timeline':
+								return $instance->excludeTimeline();
+							default:
+								return [];
+						}
+					}
+				);
+
+				if (!empty($exclude)) {
+					$IDs = array_merge($IDs, $exclude);
+				}
+			}
+		}
+
+		return $IDs;
+	}
+	protected function excludeSingle():array
+	{
+		return get_posts([
+			'post_type'		=> $this->based,
+			'posts_per_page'=> -1,
+			'fields'		=> 'ids',
+			'post_status'	=> 'publish',
+		]);
+	}
+	protected function excludeTimeline():array
+	{
+		return get_posts([
+			'post_type'		=> $this->based,
+			'posts_per_page'=> -1,
+			'fields'		=> 'ids',
+			'post_status'	=> 'publish',
+			'post_parent__not_in'	=> [0], // Only get posts with a parent
+		]);
+	}
+
 	protected function initRegistrar():void {
 		$this->registrar = match ($this->type) {
 			'post' => new Posts($this->slug, $this->singular, $this->plural),
@@ -503,6 +558,11 @@
 		}
 		return $this;
 	}
+	public function prefixWith(string $prefix):self
+	{
+		$this->prefix_with = sanitize_title($prefix);
+		return $this;
+	}
 	public function removeAll(array $flags):self
 	{
 		$flags = array_filter($flags, function($flag) {
@@ -702,6 +762,10 @@
 				}
 			}
 
+			if ($this->prefix_post_type) {
+				$this->addPostTypeRewrites();
+			}
+
 			if ($this->registrar) {
 				$this->registrar->register();
 			}
@@ -1079,4 +1143,53 @@
 			echo get_the_post_thumbnail($postID, 'tiny');
 		}
 	}
+
+	protected function addPostTypeRewrites():void
+	{
+		$for = $this->registrar->for;
+		foreach ($for as $type) {
+			$registrar = Registrar::getInstance($type);
+			if ($registrar) {
+				$base = $registrar->registrar->rewrite['slug']??$registrar->slug;
+
+				$prefix = empty($this->prefix_with) ? '' : '/'.$this->prefix_with;
+				$prefix = str_replace('//', '/', $prefix);
+
+				$slug = str_contains($this->slug, '_') ? str_replace('_','-', $this->slug) : $this->slug;
+				add_rewrite_rule(
+					$base.$prefix.'/'.$slug.'/([a-z0-9-]+)/?$',
+					'index.php?post_type='.$registrar->getBased().'&'.$this->based.'=$matches[1]',
+					'top'
+				);
+				add_rewrite_rule(
+					$base.$prefix.'/'.$slug.'/([a-z0-9-]+)/page/([0-9-]+)/?$',
+					'index.php?post_type='.$registrar->getBased().'&'.$this->based.'=$matches[1]&paged=$matches[2]',
+					'top'
+				);
+			}
+		}
+	}
+
+	public function getFeedFields():array
+	{
+		$config = $this->getConfig('feed');
+		$all = $this->getFields();
+		$img = $config['images']??['post_thumbnail'];
+		$f = $config['fields']??['post_title', 'post_date', 'post_excerpt'];
+
+		$f = array_filter($f, function($field) use ($img) {
+			return !in_array($field, $img);
+		});
+		$images = [];
+		$fields = [];
+
+		foreach($img as $i) {
+			$images[] = $all[$i];
+		}
+		foreach ($f as $x) {
+			$fields[] = $all[$x];
+		}
+
+		return [$images,$fields];
+	}
 }
diff --git a/inc/registrar/config/seo/Meta.php b/inc/registrar/config/seo/Meta.php
index 26a4ac9..f7522e1 100644
--- a/inc/registrar/config/seo/Meta.php
+++ b/inc/registrar/config/seo/Meta.php
@@ -35,6 +35,7 @@
 		if (!function_exists('tsf')){
 			return;
 		}
+
 		if ($this->hasTitle()){
 			add_filter('the_seo_framework_title_from_generation', [$this, 'filterTitle'], 10, 2);
 		}
diff --git a/inc/registrar/config/seo/Schema.php b/inc/registrar/config/seo/Schema.php
index c9e83e5..86401cc 100644
--- a/inc/registrar/config/seo/Schema.php
+++ b/inc/registrar/config/seo/Schema.php
@@ -93,49 +93,8 @@
 		add_action('wp_head', [$this, 'outputSchema'], 1);
 		add_filter('the_seo_framework_schema_graph_data', [$this, 'filterTSFSchema'], 10, 2);
 		add_filter('the_seo_framework_title_from_custom_field', [$this, 'filterTSFOGTitle'], 10, 2);
-
-		$this->maybeExcludeSingles();
 	}
-		protected function maybeExcludeSingles(): void
-		{
-			$exclude = $this->cache->remember(
-				'excludeSingles',
-				function () {
-					$exclude = [];
-					$registrar = Registrar::getInstance($this->slug);
-					if ($registrar && $registrar->hasFeature('hide_single')) {
-						$exclude = $this->excludeSingle();
-					}
-					if ($registrar && $registrar->hasFeature('is_timeline')) {
-						$exclude = array_merge($exclude, $this->excludeTimeline());
-					}
-					return $exclude;
-				}
-			);
 
-			if (!empty($exclude)) {
-				add_filter('the_seo_framework_sitemap_exclude_ids', $exclude);
-			}
-		}
-		protected function excludeSingle():array
-		{
-			return get_posts([
-				'post_type'		=> jvbCheckBase($this->slug),
-				'posts_per_page'=> -1,
-				'fields'		=> 'ids',
-				'post_status'	=> 'publish',
-			]);
-		}
-		protected function excludeTimeline():array
-		{
-			return get_posts([
-				'post_type'		=> jvbCheckBase($this->slug),
-				'posts_per_page'=> -1,
-				'fields'		=> 'ids',
-				'post_status'	=> 'publish',
-				'post_parent__not_in'	=> [0], // Only get posts with a parent
-			]);
-		}
 	public function filterTSFSchema(array $graph, ?array $args):array
 	{
 		$based = jvbCheckBase($this->slug);
@@ -487,6 +446,7 @@
 				'type'	=> 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Answer',
 				'text'	=> wp_strip_all_tags(str_replace("\n", '', $meta->get('post_content'))),
 			],
+			'answerCount'	=> 1,
 		];
 		$question = SchemaHelper::classFromConfig($question);
 		$page->setMainEntity($question);
diff --git a/inc/rest/routes/ContentRoutes.php b/inc/rest/routes/ContentRoutes.php
index dd25f8e..b5beed6 100644
--- a/inc/rest/routes/ContentRoutes.php
+++ b/inc/rest/routes/ContentRoutes.php
@@ -598,7 +598,7 @@
 		$item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
 
 		//Step 2: Get the fields for each individual posts
-		$children = get_children(['post_parent' => $post->ID, 'orderby' => 'date', 'order' => 'ASC', 'post_status' => ['publish', 'draft'], 'fields' => 'ids']);
+		$children = jvbTimelinePoints($post->ID, $post->post_type,['publish', 'draft']);
 		array_unshift($children, $post->ID);
 
 		$subFields = [];
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index 3494f5b..32d7866 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -37,10 +37,10 @@
 		parent::__construct();
 
 		// Set up cache connections
-		$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->cache->connect('post')->connect('user')->connect('taxonomy')->user();
+		$this->listsCache = Cache::for('lists')->connect('favourites', true)->user();
+		$this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true)->user();
+		$this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true)->user();
 
 		$this->valid_types = array_merge(Registrar::getRegistered('post'), Registrar::getRegistered('term'));
 
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index 76f6b61..6abb894 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/inc/rest/routes/FeedRoutes.php
@@ -149,21 +149,16 @@
 						$meta = Meta::forUser($postID);
 						$registrar = Registrar::getInstance($type);
 						break;
+					default:
+						$meta = false;
 				}
-				if (!$registrar) {
+				if (!$registrar || !$meta) {
 					return [];
 				}
-				$fields = $registrar->getFields();
+				[$images, $fields] = $registrar->getFeedFields();
 
-				//Allow custom filtering for public fields
-				if (!empty($registrar->getConfig('feed')['fields'])) {
-					$fields = array_filter($fields, function($field) use ($registrar) {
-						return in_array($field, $registrar->getConfig('feed')['fields']);
-					}, ARRAY_FILTER_USE_KEY);
-				}
-
-				$values = $meta->getAll(array_keys($fields));
-
+				$all = array_merge($images, $fields);
+				$values = $meta->getAll(array_map(function($f) { return $f['name']; }, $all));
 				$out = [
 					'fields' => $values,
 				];
@@ -173,23 +168,18 @@
 
 				//Add images
 				$imgIDs = [];
-				$temp = array_filter($fields, function($field) {
-					return in_array($field['type'], [ 'upload', 'image', 'gallery']);
-				});
-
-				foreach ($temp as $key => $config) {
-					if (array_key_exists($key, $out['fields']) && $out['fields'][$key] !== '') {
-						$IDs = array_map('absint', explode(',',$out['fields'][$key]));
-						foreach ($IDs as $ID) {
-							$imgIDs[$ID] = jvbImageData($ID);
-						}
+				$imgs = $meta->getAll(array_map(function($f) {return $f['name'];}, $images));
+				foreach ($imgs as $img) {
+					$IDs = array_map('absint', explode(',',$img));
+					foreach ($IDs as $ID) {
+						$imgIDs[$ID] = jvbImageData($ID);
 					}
 				}
 				$out['images'] = $imgIDs;
 
 				$out['id'] = $postID;
 				$out['content'] = $type;
-				$out['icon'] = $registrar->getIcon()??jvbDefaultIcon();
+				$out['icon'] = $registrar->getIcon();
 				if ($out['icon'] === '') {
 					$out['icon'] = jvbDefaultIcon();
 				}
@@ -212,8 +202,6 @@
 						$out['title'] = html_entity_decode($post->name);
 						break;
 					case 'post':
-						$out['date'] = $post->post_date;
-						$out['date_modified'] = $post->post_modified;
 						$out['user_id'] = (int)$post->post_author;
 						$out['url'] = get_the_permalink($postID);
 						$out['title']= get_the_title($postID);
@@ -253,37 +241,30 @@
 
 	protected function formatTimeline(int $postID, WP_Post $post):array
 	{
-		if (!$this->timelineSharedFields || !$this->timelineUniqueFields){
-			$this->initTimelineFields($post->post_type);
-		}
-		$item = $this->formatItem($postID, 'post', true);
 		//Step 1: Get the fields that apply to all posts
-		$mainMeta = Meta::forPost($post->ID);
-		$item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
+		$item = $this->formatItem($postID, 'post', true);
 
 		//Step 2: Get the fields for each individual posts
-		$children = get_children(['post_parent' => $post->ID, 'orderby' => 'date', 'order' => 'ASC', 'post_status' => ['publish'], 'fields'=> 'ids']);
-		array_unshift($children, $post->ID);
+		$children = jvbTimelinePoints($postID, $post->post_type);
+		$last = $children[array_key_last($children)];
+			$lastMeta = Meta::forPost($last);
+			$lastImg = $lastMeta->get('post_thumbnail');
 
-		$item['taxonomies'] = $this->extractTaxonomies($item['fields'], $postID, jvbNoBase($post->post_type));
+			$item['fields']['after'] = $lastImg;
+			$item['images'][$lastImg] = jvbImageData((int) $lastImg);
+			$item['fields']['post_modified'] = $lastMeta->get('post_date');
 
-		$subFields = [];
-		$images = [];
-		foreach ($children as $child) {
-			$meta = Meta::forPost($child);
-			$f = $meta->getAll($this->timelineUniqueFields);
-			$f =  ['id' => $child] + $f;
-			$subFields[] = $f;
-			$item['taxonomies'] = array_merge($item['taxonomies'], $this->extractTaxonomies($f, $postID, jvbNoBase($post->post_type)));
-			$images[$f['post_thumbnail']] = jvbImageData((int) $f['post_thumbnail']);
+			$count = count(array_filter($children, function ($child) {
+				$tmp = Meta::forPost($child);
+				return empty($tmp->get('is_update')) || $tmp->get('is_update') !==1;
+			}));
+
+		foreach($item['taxonomies'] as $tax => $items) {
+			$item[$tax] = array_keys($items);
 		}
-		$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_gallery'] = $subFields;
-		$item['images'] = $item['images'] + $images;
-
+		$item['number'] = $count;
+		$item['fields']['before'] = $item['fields']['post_thumbnail'];
 
 		return $item;
 	}
@@ -546,7 +527,6 @@
 				// Add term to tax query
 				$args['tax_query'][] = [
 					'taxonomy' => $registrar->getBased(),
-//					'field' => 'term_id',
 					'terms' => [(int)$context['id']],
 				];
 				break;
@@ -630,7 +610,7 @@
 
 	protected function handleContentTaxonomies(array $args): array
 	{
-
+		//TODO: This needs to be better. We have CustomTable now, maybe pass it along to that?
 		$taxonomy = jvbNoBase($args['post_type']);
 		global $wpdb;
 		$table = $wpdb->prefix . BASE . 'content_' . $taxonomy;
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 0ef1550..2e863ee 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -1,6 +1,7 @@
 <?php
 namespace JVBase\rest\routes;
 
+use JVBase\managers\Cache;
 use JVBase\registrar\Registrar;
 use JVBase\rest\Rest;
 use JVBase\rest\Route;
@@ -711,24 +712,30 @@
 		}
 	}
 
+	public static function auth():array
+	{
+		return (new self)->buildAuth();
+	}
+
 	protected function buildAuth(?int $user = null): array
 	{
-		if (is_user_logged_in()) {
-			$user = ($user) ?: get_current_user_id();
-			return [
-				'authenticated' => true,
-				'user' => $user,
-				'nonces' => $this->getUserNonces($user)
-			];
-		}
+		$userId = $user ?? (is_user_logged_in() ? get_current_user_id() : 0);
+		$cacheKey = $userId ?: 'guest';
 
-		return [
-			'authenticated' => false,
-			'user' => false,
-			'nonces' => [
-				'wp_rest' => wp_create_nonce('wp_rest')
-			]
-		];
+		return Cache::for('auth', 300)->remember($cacheKey, function() use ($userId) {
+			if ($userId) {
+				return [
+					'authenticated' => true,
+					'user'          => $userId,
+					'nonces'        => $this->getUserNonces($userId),
+				];
+			}
+			return [
+				'authenticated' => false,
+				'user'          => false,
+				'nonces'        => ['wp_rest' => wp_create_nonce('wp_rest')],
+			];
+		});
 	}
 	protected function getUserNonces(int $userID):array {
 		$nonces = [
diff --git a/inc/rest/routes/QueueRoutes.php b/inc/rest/routes/QueueRoutes.php
index 954e0a6..c1017be 100644
--- a/inc/rest/routes/QueueRoutes.php
+++ b/inc/rest/routes/QueueRoutes.php
@@ -88,7 +88,7 @@
 	{
 		$params = $request->get_params();
 		$user_id = absint($params['user']);
-		$this->cache = Cache::for($user_id.'_queue');
+		$this->cache = Cache::for('queue')->user();
 		$status = sanitize_text_field($params['status']);
 		$ids = !empty($params['ids'])
 			? array_map('trim', array_map('sanitize_text_field', explode(',', $params['ids'])))
diff --git a/inc/utility/Image.php b/inc/utility/Image.php
index 6c7f41d..0626891 100644
--- a/inc/utility/Image.php
+++ b/inc/utility/Image.php
@@ -81,7 +81,7 @@
 					$postSlug = jvbNoBase($tax->taxonomy);
 				}elseif (is_post_type_archive()) {
 					$obj = get_queried_object();
-					$postSlug = jvbNoBase($obj->post_type);
+					$postSlug = jvbNoBase($obj->name);
 				}
 
 			}
diff --git a/jvb.php b/jvb.php
index 226515f..e4e4bb7 100644
--- a/jvb.php
+++ b/jvb.php
@@ -463,7 +463,7 @@
         ));
     }
 
-    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-queue');
+//    jvbAddScriptDependency('jvb-feed-view-script', 'jvb-queue');
     jvbAddScriptDependency('jvb-feed-view-script', 'jvb-selector');
     jvbAddScriptDependency('jvb-feed-view-script', 'jvb-data-store');
     jvbAddScriptDependency('jvb-feed-view-script', 'jvb-cache');
@@ -596,3 +596,10 @@
 		debug_print_backtrace();
 	}
 } );
+
+
+add_filter('the_seo_framework_sitemap_exclude_ids', 'jvb_maybe_exclude_singles', 8, 1);
+function jvb_maybe_exclude_singles(array $IDs):array
+{
+	return Registrar::maybeExcludeSingles($IDs);
+}
diff --git a/src/feed/block.json b/src/feed/block.json
index 3fec37e..8a5498f 100644
--- a/src/feed/block.json
+++ b/src/feed/block.json
@@ -7,7 +7,7 @@
     "icon": "grid-view",
     "description": "Displays a filterable feed of registered content types",
     "keywords": [ "feed", "grid" ],
-    "version": "0.9.0",
+    "version": "1.0.0",
     "textdomain": "jvb",
     "supports": {
         "html": false,
diff --git a/src/feed/style.scss b/src/feed/style.scss
index 5bf4033..f18c929 100644
--- a/src/feed/style.scss
+++ b/src/feed/style.scss
@@ -1088,6 +1088,9 @@
 				opacity: .8;
 			}
 		}
+		ul {
+			margin: 0;
+		}
 
 		&[data-timeline] {
 			.images {
diff --git a/src/feed/view.js b/src/feed/view.js
index 5abe47d..a8c9946 100644
--- a/src/feed/view.js
+++ b/src/feed/view.js
@@ -7,6 +7,7 @@
 		this.error = window.jvbError;
 		this.cache = new window.jvbCache('feed');
 		this.templates = window.jvbTemplates;
+		this.isFirstLoad = true;
 
 		this.config = {
 			contextId: '',
@@ -17,35 +18,23 @@
 			... this.container.dataset
 		};
 
+
 		this.init();
 	}
 	init() {
 		this.initElements();
 		this.defineTemplates();
+
 		this.initListeners();
 		this.initFilters();
 
-		if ('requestIdleCallback' in window) {
-			requestIdleCallback(() => {
-				this.initStore();
-				this.initTaxonomies();
+		this.initStore();
+		this.initTaxonomies().then(r => {});
 
-				this.processCachedFilters();
-				this.processURLFilters();
-				this.updateFilterUI();
-				this.initGallery();
-			}, { timeout: 2000 });
-		} else {
-			setTimeout(() => {
-				this.initStore();
-				this.initTaxonomies();
-
-				this.processCachedFilters();
-				this.processURLFilters();
-				this.updateFilterUI();
-				this.initGallery();
-			}, 100);
-		}
+		this.processCachedFilters();
+		this.processURLFilters();
+		this.updateFilterUI();
+		this.initGallery();
 	}
 
 	initElements() {
@@ -486,7 +475,8 @@
 				TTL: 6 * 60 * 60 * 1000, //6 hours
 				showLoading: true,
 				required: 'content',
-			}
+			},
+			2
 		);
 
 		this.store = store.feed;
@@ -494,6 +484,14 @@
 		this.store.subscribe((event, data) => {
 			switch (event) {
 				case 'data-loaded':
+					if (this.isFirstLoad) {
+						//We rendered the first page in php already
+						this.isFirstLoad = false;
+						return;
+					}
+					// if (this.store.filters.page === 1) {
+					// 	return;
+					// }
 					this.renderItems(data.items);
 					this.ui.buttons.loadMore.hidden = true;
 					if (this.store.lastResponse && this.store.lastResponse?.has_more) {
@@ -659,10 +657,10 @@
 		];
 
 		if (afterEl) {
-			afterEl.textContent = `After ${item.number - 1} Tx`;
+			afterEl.textContent = `After ${item.number} Tx`;
 		}
 		if (number) {
-			number.textContent = item.number - 1;
+			number.textContent = item.number;
 		}
 		if (started) {
 			this.formatTimeField(started, item.fields.timeline[0]['post_date']);
@@ -710,7 +708,6 @@
 					if (manyRefs.fields) {
 						for (let field of manyRefs.fields) {
 							if (isTimeline && ['timeline','number'].includes(field.dataset.field)) continue;
-
 							const value = Object.hasOwn(data.fields, field.dataset.field)? data.fields[field.dataset.field] : false;
 							if (!value) {
 								field.remove();

--
Gitblit v1.10.0