From 235ce5716edc2f7cbe80fdccf26eac7269587839 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 08 Jun 2026 04:38:18 +0000
Subject: [PATCH] =FavouritesManager.php and FavouritesRoutes.php fixes. Moving all logic to FavouritesManager.php. Still some left to do

---
 assets/js/concise/Tabs.js |  480 +++++++++++++++++++++++++++++++++---------------------------
 1 files changed, 264 insertions(+), 216 deletions(-)

diff --git a/assets/js/concise/Tabs.js b/assets/js/concise/Tabs.js
index 4dfce20..b93c083 100644
--- a/assets/js/concise/Tabs.js
+++ b/assets/js/concise/Tabs.js
@@ -2,227 +2,275 @@
  * Handles the tabs functionality, given the html requirements are met
  */
 class TabsContainer{
-    constructor(container, callbacks = {}, parent = null){
-        this.tabs = container.querySelector('.tabs');
-        this.a11y = window.jvbA11y;
+	constructor() {
+		this.a11y = window.jvbA11y;
+		this.error = window.jvbError;
 
-		this.updateURL = true;
+		this.subscribers = new Set();
+		this.tabs = new Map();
 
-        this.parent = parent;
-        this.childTabs = new Map();
-		if ('updateURL' in callbacks && callbacks.updateURL === false) {
-			this.updateURL = false;
-		}
-        this.callbacks = callbacks;
-
-
-        this.activeTab = (this.updateURL) ? this.getInitialTabFromHash() :
-            container.querySelector('button.tab.active')?.dataset.tab;
-
-        this.container = container;
-
-        if (this.tabs) {
-            this.tabs.addEventListener('click', e => {
-                const tab = e.target.closest('[data-tab]');
-                if (tab) {
-					let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true;
-                    this.switchTab(tab.dataset.tab, update);
-                }
-            });
-        }
-
-        // Check for and initialize child tab containers
-        this.initializeChildTabs();
-
-        // Find and set up select dropdown listeners
-        this.selectDropdown = document.querySelector('select.tab-list');
-        if (this.selectDropdown) {
-            this.selectDropdown.addEventListener('change', e => {
-
-				let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true;
-                this.switchTab(e.target.value, update);
-            });
-        }
-
-		let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true;
-
-		if (!this.activeTab){
-			this.activeTab = document.querySelector('button.tab')?.dataset.tab;
-		}
-
-		this.switchTab(this.activeTab, update);
-    }
-
-    /**
-     * Auto-detect and initialize child tab containers
-     */
-    initializeChildTabs() {
-
-        this.tabs.querySelectorAll('button').forEach(tab => {
-            let hasChildren = this.container.querySelector(`.tab-content[data-tab="${tab.dataset['tab']}"]`);
-
-            if(hasChildren && hasChildren.querySelector('.tabs')){
-                let container = this.container.querySelector(`.tab-content[data-tab="${tab.dataset['tab']}"]`);
-				let tabs = new window.jvbTabs(container, {updateURL: false}, this);
-                this.childTabs.set(tab.dataset.tab, tabs);
-            }
-        });
-        // Find all tab content panels in this container
-        // const tabPanels = this.container.querySelectorAll('.tab-content');
-        // tabPanels.forEach(panel => {
-        //     const tabId = panel.dataset.tab;
-        //     if (!tabId) return;
-        //
-        //     // Look for nested tab containers within this panel
-        //     const nestedTabContainers = panel.querySelectorAll('.tabs, [data-tabs]');
-        //
-        //     nestedTabContainers.forEach(nestedContainer => {
-        //         // Create a new TabsContainer for this nested container
-        //         const childContainer = new TabsContainer(nestedContainer, {}, this);
-        //
-        //         // Store the child container
-        //         this.childTabs.set(tabId, childContainer);
-        //     });
-        // });
-    }
-
-    /**
-     * Get initial tab from URL hash
-     * @returns {string|false} Tab identifier from hash or null if not present
-     */
-    getInitialTabFromHash() {
-        if (!window.location.hash) return false;
-
-        const hash = window.location.hash.substring(1); // Remove the # character
-        const pathParts = hash.split('/');
-
-        // If this is a root container, check first path part
-        if (!this.parent) {
-            const rootTabId = pathParts[0];
-            // Check if this hash corresponds to a valid tab
-            const matchingTab = this.tabs.querySelector(`[data-tab="${rootTabId}"]`);
-            if (matchingTab) {
-                return rootTabId;
-            }
-        }
-        // If this is a child container, check if our parent's active tab matches the path
-        else if (this.parent && pathParts.length > 1) {
-            const parentIdx = this.getParentDepth();
-            if (parentIdx < pathParts.length) {
-                const childTabId = pathParts[parentIdx];
-                const matchingTab = this.tabs.querySelector(`[data-tab="${childTabId}"]`);
-                if (matchingTab) {
-                    return childTabId;
-                }
-            }
-        }
-
-        return null;
-    }
-
-    /**
-     * Calculate the depth of this tabs container in the hierarchy
-     * @returns {number} The depth (0 for root, 1 for first level child, etc.)
-     */
-    getParentDepth() {
-        let depth = 0;
-        let parent = this.parent;
-
-        while (parent) {
-            depth++;
-            parent = parent.parent;
-        }
-
-        return depth;
-    }
-
-    /**
-     * Build the full path for this tab including all parent tabs
-     * @param {string} tabId - The current tab ID
-     * @returns {string} The full path including parent tabs
-     */
-    getFullTabPath(tabId) {
-        if (!this.parent) {
-            return tabId;
-        }
-
-        // Get parent's active tab path and append this tab
-        const parentPath = this.parent.getFullTabPath(this.parent.activeTab);
-        return `${parentPath}/${tabId}`;
-    }
-
-    /**
-     * Switch between tabs
-     * @param {string} tab - Tab to switch to ('items' or 'lists')
-     * @param {boolean} updateHistory - Whether to push the state to the url
-     */
-	switchTab(tab, updateHistory = false) {
-		document.activeElement?.blur();
-
-		// Update tab buttons
-		this.tabs.querySelectorAll('[data-tab]').forEach(tabBtn => {
-			tabBtn.classList.toggle('active', tabBtn.dataset.tab === tab);
-			tabBtn.setAttribute('aria-selected', tabBtn.dataset.tab === tab);
-		});
-
-		// Update tab panels
-		this.container.querySelectorAll('.tab-content').forEach(content => {
-			content.classList.toggle('active', content.dataset.tab === tab);
-			content.setAttribute('aria-hidden', content.dataset.tab !== tab);
-			content.hidden = content.dataset.tab !== tab;
-		});
-
-		// Update state
-		this.activeTab = tab;
-		if (this.callbacks[tab]) {
-			this.callbacks[tab]();
-		}
-
-		// Activate first child tab if this tab has children
-		const childContainer = this.childTabs.get(tab);
-		if (childContainer) {
-			const firstTab = childContainer.container.querySelector('button.tab')?.dataset.tab;
-			if (firstTab) {
-				childContainer.switchTab(firstTab, false);
-			}
-		}
-
-		// Update URL hash with full path (only from root container)
-		if (updateHistory) {
-			if (!this.parent) {
-				window.history.pushState({ tab: tab }, '', `#${tab}`);
-			} else {
-				// This is a child container, notify parent to update URL
-				this.parent.updateUrlFromChild();
-			}
-		}
-
-		// Update select dropdown if it exists
-		if (this.selectDropdown && this.selectDropdown.querySelector(`option[value="${tab}"]`)) {
-			this.selectDropdown.value = tab;
-		}
-
-		// Announce to screen readers
-		this.a11y.announce(`Switched to ${tab} tab`);
+		this.hasHash = false;
+		this.init();
+	}
+	init() {
+		this.initElements();
+		this.initListeners();
 	}
 
-    /**
-     * Update URL when a child tab changes
-     */
-    updateUrlFromChild() {
-		console.log('Updating URL');
-		if (('updateURL' in this.callbacks) ? this.callbacks.updateURL : true) {
-			if (!this.parent) {
-				// Only the root container should update the URL
-				const fullPath = this.getFullTabPath(this.activeTab);
-				window.history.pushState({ tab: fullPath }, '', `#${fullPath}`);
-			} else {
-				// Propagate up to the root
-				this.parent.updateUrlFromChild();
+	initElements() {
+		this.selectors = {
+			nav: '.tabs',
+			tab: '[data-tab]',
+			active: 'button.tab.active',
+			section: '.tab-content',
+			button: 'button.tab',
+			select: 'select.tab-list',
+		};
+	}
+	initListeners() {
+		this.clickHandler = this.handleClick.bind(this);
+		this.changeHandler = this.handleChange.bind(this);
+	}
+		handleClick(e) {
+			let config = this.getConfig(e.target);
+			if (!config) return;
+
+			const tab = e.target.closest(this.selectors.tab);
+			if (tab) {
+				this.switchTab(tab.dataset.tab, config);
 			}
 		}
-    }
+
+		handleChange(e) {
+			let config = this.getConfig(e.target);
+			if (!config) return;
+			if (!config) return;
+			this.switchTab(e.target.value, config);
+		}
+
+	/**
+	 *
+	 * @param {HTMLElement} container
+	 * @param {object} options
+	 */
+	registerTab(container, options = {}) {
+		if (!container) return false;
+
+		let	ui = window.uiFromSelectors(this.selectors, container);
+		if (!ui.nav || !ui.section) {
+			console.error('No tab navigation or section found');
+			return false;
+		}
+		let tabsId = window.generateID('tab');
+
+		container.dataset.tabsId = tabsId;
+		ui.buttons = Array.from(container.querySelectorAll(this.selectors.button));
+		ui.sections = Array.from(container.querySelectorAll(this.selectors.section));
+		ui.sections.forEach(section => {
+			if (section.querySelector('.tabs')) {
+				options.hasChildren = true;
+				this.registerTab(section, {parent: tabsId});
+			}
+		});
+
+		if (ui.select) {
+			ui.options = Array.from(ui.select.querySelectorAll('option'));
+		}
+
+		let config = {
+			id: tabsId,
+			ui: ui,
+			updateURL: options.updateURL ?? true
+		};
+
+		//Add listeners
+		ui.nav.addEventListener('click', this.clickHandler);
+		ui.select?.addEventListener('change', this.changeHandler);
+		this.tabs.set(tabsId, config);
+		this.determineActiveTab(config);
+
+		return config;
+	}
+
+	determineActiveTab(config) {
+		if (this.getInitialTabFromHash()) {
+			let updated = this.tabs.get(config.id);
+			if (updated.activeTab && config.activeTab && updated.activeTab === config.activeTab) return;
+		}
+		let tab = config.ui.buttons[0].dataset.tab??false;
+		if (tab) {
+			this.switchTab(tab, config);
+		}
+	}
+	getInitialTabFromHash() {
+		if (this.hasHash || !window.location.search) return false;
+		const params = new URLSearchParams(window.location.search);
+		const hash = params.get('tab');
+		if (!hash) return false;
+
+		const parts = hash.split('|');
+
+		parts.forEach(part => {
+			// Find the config that has a button matching this part
+			const conf = Array.from(this.tabs.values()).find(tab =>
+				tab.ui.buttons.some(btn => btn.dataset.tab === part)
+			);
+
+			if (conf) {
+				this.switchTab(part, conf);
+			}
+		});
+
+		this.hasHash = true;
+		return true;
+	}
+
+	/**
+	 *
+	 * @param {HTMLElement} container
+	 */
+	removeTab(container) {
+		if (!container || !container.dataset.tabsId) return;
+		let config = this.tabs.get(container.dataset.tabsId);
+		if (!config) return;
+		config.ui.nav.removeEventListener('click', this.clickHandler);
+		config.ui.select?.removeEventListener('change', this.changeHandler);
+		this.tabs.delete(container.dataset.tabsId);
+	}
+
+	/**
+	 *
+	 * @param {string} tab
+	 * @param {string|Object} config Either the key of the tabs instance, or a tab config object
+	 */
+	switchTab(tab, config) {
+		config = (typeof config === 'string') ? this.tabs.get(config) : config;
+		if (!config) return;
+		let activeTab = config.ui.sections.filter(section => section.classList.contains('active'));
+
+		if (Object.hasOwn(config, 'preCheck') && !config.preCheck(activeTab[0], config)) return;
+		if (document.activeElement && this.isInTabs(document.activeElement, config)) document.activeElement.blur();
+
+		config.ui.buttons.forEach((btn, index) => {
+			btn.classList.remove('active', 'previous', 'next');
+			let isActive = btn.dataset.tab === tab;
+			btn.setAttribute('aria-selected', isActive);
+			if (isActive) {
+				btn.classList.add('active');
+				let prv = Math.max(index - 1, 0);
+				let next = Math.min(index+1, config.ui.buttons.length -1);
+				if (prv !== index) {
+					config.ui.buttons[prv]?.classList.add('previous');
+				}
+				if (next !== index) {
+					config.ui.buttons[next]?.classList.add('next');
+				}
+			}
+		});
+
+		config.ui.sections.forEach((section, index) => {
+			let isActive = section.dataset.tab === tab;
+			section.classList.toggle('active', isActive);
+			section.setAttribute('aria-hidden', !isActive);
+			section.hidden = !isActive;
+		});
+
+		this.notify('tab-switched', {
+			previous: config.activeTab,
+			current: tab,
+			config: config
+		});
+		config.activeTab = tab;
+
+		this.tabs.set(config.id, config);
+		if (config?.hasChildren) this.updateChildTabs(config.id);
+		if (config?.updateURL) this.updateURL(config);
+		if (config.ui.select) this.maybeUpdateSelect(tab, config);
+
+		this.a11y.announce(`Switched to ${tab} tab`);
+	}
+	updateChildTabs(id) {
+		Array.from(this.tabs.values()).filter(conf => conf.parent === id).forEach(inst => {
+			let firstBtn = inst.ui.buttons[0].dataset.tab??false;
+			if (firstBtn) {
+				this.switchTab(firstBtn, inst);
+			}
+		});
+	}
+
+	updateURL(config) {
+		if (!config.updateURL) return;
+		let hash = this.checkAncestorsHash(config);
+		if (hash) {
+			window.history.pushState({tab:config.activeTab},'',`?tab=${hash}`);
+		}
+	}
+	checkAncestorsHash(conf) {
+		const parts = [];
+		let current = conf;
+
+		while (current) {
+			parts.unshift(current.activeTab);
+			current = current.parent ? this.tabs.get(current.parent) : null;
+		}
+
+		return parts.join('|');
+	}
+	maybeUpdateSelect(tab, config) {
+		if (!config.ui.select || !Object.hasOwn(config, 'options')) return;
+		config.options.forEach(option => {
+			if (option.value === tab) {
+				config.ui.select.value = tab;
+				return;
+			}
+		});
+
+	}
+
+
+	/**
+	 * Event system
+	 */
+	subscribe(callback) {
+		this.subscribers.add(callback);
+		return () => this.subscribers.delete(callback);
+	}
+
+	notify(event, data) {
+		this.subscribers.forEach(cb => cb(event, data));
+	}
+
+	/**************************************************************
+	 UTILITY
+	**************************************************************/
+	/**
+	 *
+	 * @param {HTMLElement} target
+	 * @returns {object|boolean}
+	 */
+	getConfig(target) {
+		const instance = target.closest('[data-tabs-id]');
+		if (!instance) return false;
+		const config = this.tabs.get(instance.dataset.tabsId);
+		if (!config) return false;
+		return config;
+	}
+	isInTabs(target, config) {
+		return config.ui.sections.some(section => section.contains(target));
+	}
+	/**************************************************************
+	 CLEANUP
+	**************************************************************/
+	destroy() {
+		this.subscribers.clear();
+
+		Array.from(this.tabs.values()).forEach(tab => {
+			tab.ui.nav.removeEventListener('click', this.clickHandler);
+			tab.ui.select?.removeEventListener('change', this.changeHandler);
+		});
+	}
 }
 
-window.jvbTabs = TabsContainer;
-
+document.addEventListener('DOMContentLoaded', function() {
+	window.jvbTabs = new TabsContainer();
+});

--
Gitblit v1.10.0