// SPORE UI Framework - Component-based architecture with pub/sub system // Lightweight logger with level gating const logger = { debug: (...args) => { try { if (window && window.DEBUG) { console.debug(...args); } } catch (_) { /* no-op */ } }, info: (...args) => console.info(...args), warn: (...args) => console.warn(...args), error: (...args) => console.error(...args), }; if (typeof window !== 'undefined') { window.logger = window.logger || logger; } // Event Bus for pub/sub communication class EventBus { constructor() { this.events = new Map(); } // Subscribe to an event subscribe(event, callback) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(callback); // Return unsubscribe function return () => { const callbacks = this.events.get(event); if (callbacks) { const index = callbacks.indexOf(callback); if (index > -1) { callbacks.splice(index, 1); } } }; } // Publish an event publish(event, data) { if (this.events.has(event)) { this.events.get(event).forEach(callback => { try { callback(data); } catch (error) { console.error(`Error in event callback for ${event}:`, error); } }); } } // Unsubscribe from an event unsubscribe(event, callback) { if (this.events.has(event)) { const callbacks = this.events.get(event); const index = callbacks.indexOf(callback); if (index > -1) { callbacks.splice(index, 1); } } } // Clear all events clear() { this.events.clear(); } } // Base ViewModel class with enhanced state management class ViewModel { constructor() { this._data = {}; this._listeners = new Map(); this._eventBus = null; this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc. this._previousData = {}; // Store previous data for change detection } // Set the event bus for this view model setEventBus(eventBus) { this._eventBus = eventBus; } // Get data property get(property) { return this._data[property]; } // Set data property and notify listeners set(property, value) { console.log(`ViewModel: Setting property '${property}' to:`, value); // Check if the value has actually changed const hasChanged = this._data[property] !== value; if (hasChanged) { // Store previous value for change detection this._previousData[property] = this._data[property]; // Update the data this._data[property] = value; console.log(`ViewModel: Property '${property}' changed, notifying listeners...`); this._notifyListeners(property, value, this._previousData[property]); } else { console.log(`ViewModel: Property '${property}' unchanged, skipping notification`); } } // Set multiple properties at once with change detection setMultiple(properties) { const changedProperties = {}; // Determine changes and update previousData snapshot per key Object.keys(properties).forEach(key => { const newValue = properties[key]; const oldValue = this._data[key]; if (oldValue !== newValue) { this._previousData[key] = oldValue; changedProperties[key] = newValue; } }); // Apply all properties Object.keys(properties).forEach(key => { this._data[key] = properties[key]; }); // Notify listeners only for changed properties with accurate previous values Object.keys(changedProperties).forEach(key => { this._notifyListeners(key, this._data[key], this._previousData[key]); }); if (Object.keys(changedProperties).length > 0) { console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties)); } } // Subscribe to property changes subscribe(property, callback) { if (!this._listeners.has(property)) { this._listeners.set(property, []); } this._listeners.get(property).push(callback); // Return unsubscribe function return () => { const callbacks = this._listeners.get(property); if (callbacks) { const index = callbacks.indexOf(callback); if (index > -1) { callbacks.splice(index, 1); } } }; } // Notify listeners of property changes _notifyListeners(property, value, previousValue) { console.log(`ViewModel: _notifyListeners called for property '${property}'`); if (this._listeners.has(property)) { const callbacks = this._listeners.get(property); console.log(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`); callbacks.forEach((callback, index) => { try { console.log(`ViewModel: Calling listener ${index} for property '${property}'`); callback(value, previousValue); } catch (error) { console.error(`Error in property listener for ${property}:`, error); } }); } else { console.log(`ViewModel: No listeners found for property '${property}'`); } } // Publish event to event bus publish(event, data) { if (this._eventBus) { this._eventBus.publish(event, data); } } // Get all data getAll() { return { ...this._data }; } // Clear all data clear() { this._data = {}; this._listeners.clear(); } // UI State Management Methods setUIState(key, value) { this._uiState.set(key, value); } getUIState(key) { return this._uiState.get(key); } getAllUIState() { return new Map(this._uiState); } clearUIState(key) { if (key) { this._uiState.delete(key); } else { this._uiState.clear(); } } // Check if a property has changed hasChanged(property) { return this._data[property] !== this._previousData[property]; } // Get previous value of a property getPrevious(property) { return this._previousData[property]; } // Batch update with change detection batchUpdate(updates, options = {}) { const { preserveUIState = true, notifyChanges = true } = options; // Optionally preserve UI state snapshot const currentUIState = preserveUIState ? new Map(this._uiState) : null; // Track which keys actually change and what the previous values were const changedKeys = []; Object.keys(updates).forEach(key => { const newValue = updates[key]; const oldValue = this._data[key]; if (oldValue !== newValue) { this._previousData[key] = oldValue; this._data[key] = newValue; changedKeys.push(key); } else { // Still apply to ensure consistency if needed this._data[key] = newValue; } }); // Restore UI state if requested if (preserveUIState && currentUIState) { this._uiState = currentUIState; } // Notify listeners for changed keys if (notifyChanges) { changedKeys.forEach(key => { this._notifyListeners(key, this._data[key], this._previousData[key]); }); } } } // Base Component class with enhanced state preservation class Component { constructor(container, viewModel, eventBus) { this.container = container; this.viewModel = viewModel; this.eventBus = eventBus; this.isMounted = false; this.unsubscribers = []; this.uiState = new Map(); // Local UI state for this component // Set event bus on view model if (this.viewModel) { this.viewModel.setEventBus(eventBus); } // Bind methods this.render = this.render.bind(this); this.mount = this.mount.bind(this); this.unmount = this.unmount.bind(this); this.updatePartial = this.updatePartial.bind(this); } // Mount the component mount() { if (this.isMounted) return; console.log(`${this.constructor.name}: Starting mount...`); this.isMounted = true; this.setupEventListeners(); this.setupViewModelListeners(); this.render(); console.log(`${this.constructor.name}: Mounted successfully`); } // Unmount the component unmount() { if (!this.isMounted) return; this.isMounted = false; this.cleanupEventListeners(); this.cleanupViewModelListeners(); console.log(`${this.constructor.name} unmounted`); } // Pause the component (keep alive but pause activity) pause() { if (!this.isMounted) return; console.log(`${this.constructor.name}: Pausing component`); // Pause any active timers or animations if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } // Pause any ongoing operations this.isPaused = true; // Override in subclasses to pause specific functionality this.onPause(); } // Resume the component resume() { if (!this.isMounted || !this.isPaused) return; console.log(`${this.constructor.name}: Resuming component`); this.isPaused = false; // Restart any necessary timers or operations this.onResume(); // Re-render if needed if (this.shouldRenderOnResume()) { this.render(); } } // Override in subclasses to handle pause-specific logic onPause() { // Default implementation does nothing } // Override in subclasses to handle resume-specific logic onResume() { // Default implementation does nothing } // Override in subclasses to determine if re-render is needed on resume shouldRenderOnResume() { // Default: don't re-render on resume return false; } // Setup event listeners (override in subclasses) setupEventListeners() { // Override in subclasses } // Setup view model listeners (override in subclasses) setupViewModelListeners() { // Override in subclasses } // Cleanup event listeners (override in subclasses) cleanupEventListeners() { // Override in subclasses } // Cleanup view model listeners (override in subclasses) cleanupViewModelListeners() { // Override in subclasses } // Render the component (override in subclasses) render() { // Override in subclasses } // Partial update method for efficient data updates updatePartial(property, newValue, previousValue) { // Override in subclasses to implement partial updates console.log(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue }); } // UI State Management Methods setUIState(key, value) { this.uiState.set(key, value); // Also store in view model for persistence across refreshes if (this.viewModel) { this.viewModel.setUIState(key, value); } } getUIState(key) { // First try local state, then view model state return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null); } getAllUIState() { const localState = new Map(this.uiState); const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map(); // Merge states, with local state taking precedence const mergedState = new Map(viewModelState); localState.forEach((value, key) => mergedState.set(key, value)); return mergedState; } clearUIState(key) { if (key) { this.uiState.delete(key); if (this.viewModel) { this.viewModel.clearUIState(key); } } else { this.uiState.clear(); if (this.viewModel) { this.viewModel.clearUIState(); } } } // Restore UI state from view model restoreUIState() { if (this.viewModel) { const viewModelState = this.viewModel.getAllUIState(); viewModelState.forEach((value, key) => { this.uiState.set(key, value); }); } } // Helper method to add event listener and track for cleanup addEventListener(element, event, handler) { element.addEventListener(event, handler); this.unsubscribers.push(() => { element.removeEventListener(event, handler); }); } // Helper method to subscribe to event bus and track for cleanup subscribeToEvent(event, handler) { const unsubscribe = this.eventBus.subscribe(event, handler); this.unsubscribers.push(unsubscribe); } // Helper method to subscribe to view model property and track for cleanup subscribeToProperty(property, handler) { if (this.viewModel) { const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => { // Call handler with both new and previous values for change detection handler(newValue, previousValue); }); this.unsubscribers.push(unsubscribe); } } // Helper method to find element within component container findElement(selector) { return this.container.querySelector(selector); } // Helper method to find all elements within component container findAllElements(selector) { return this.container.querySelectorAll(selector); } // Helper method to set innerHTML safely setHTML(selector, html) { console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`); let element; if (selector === '') { // Empty selector means set HTML on the component's container itself element = this.container; console.log(`${this.constructor.name}: Using component container for empty selector`); } else { // Find element within the component's container element = this.findElement(selector); } if (element) { console.log(`${this.constructor.name}: Element found, setting innerHTML`); element.innerHTML = html; console.log(`${this.constructor.name}: innerHTML set successfully`); } else { console.error(`${this.constructor.name}: Element not found for selector '${selector}'`); } } // Helper method to set text content safely setText(selector, text) { const element = this.findElement(selector); if (element) { element.textContent = text; } } // Helper method to add/remove CSS classes setClass(selector, className, add = true) { const element = this.findElement(selector); if (element) { if (add) { element.classList.add(className); } else { element.classList.remove(className); } } } // Helper method to set CSS styles setStyle(selector, property, value) { const element = this.findElement(selector); if (element) { element.style[property] = value; } } // Helper method to show/hide elements setVisible(selector, visible) { const element = this.findElement(selector); if (element) { element.style.display = visible ? '' : 'none'; } } // Helper method to enable/disable elements setEnabled(selector, enabled) { const element = this.findElement(selector); if (element) { element.disabled = !enabled; } } // Reusable render helpers renderLoading(customHtml) { const html = customHtml || `
Loading...
`; this.setHTML('', html); } renderError(message) { const safe = this.escapeHtml(String(message || 'An error occurred')); const html = `
Error:
${safe}
`; this.setHTML('', html); } renderEmpty(customHtml) { const html = customHtml || `
No data
`; this.setHTML('', html); } // Basic HTML escaping for dynamic values escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Tab helpers setupTabs(container = this.container, options = {}) { const { onChange } = options; const tabButtons = container.querySelectorAll('.tab-button'); const tabContents = container.querySelectorAll('.tab-content'); tabButtons.forEach(button => { this.addEventListener(button, 'click', (e) => { e.stopPropagation(); const targetTab = button.dataset.tab; this.setActiveTab(targetTab, container); if (typeof onChange === 'function') { try { onChange(targetTab); } catch (_) {} } }); }); tabContents.forEach(content => { this.addEventListener(content, 'click', (e) => { e.stopPropagation(); }); }); } setActiveTab(tabName, container = this.container) { const tabButtons = container.querySelectorAll('.tab-button'); const tabContents = container.querySelectorAll('.tab-content'); tabButtons.forEach(btn => btn.classList.remove('active')); tabContents.forEach(content => content.classList.remove('active')); const activeButton = container.querySelector(`[data-tab="${tabName}"]`); const activeContent = container.querySelector(`#${tabName}-tab`); if (activeButton) activeButton.classList.add('active'); if (activeContent) activeContent.classList.add('active'); logger.debug(`${this.constructor.name}: Active tab set to '${tabName}'`); } } // Application class to manage components and routing class App { constructor() { this.eventBus = new EventBus(); this.components = new Map(); this.currentView = null; this.routes = new Map(); this.navigationInProgress = false; this.navigationQueue = []; this.lastNavigationTime = 0; this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300; // cooldown between navigations // Component cache to keep components alive this.componentCache = new Map(); this.cachedViews = new Set(); } // Register a route registerRoute(name, componentClass, containerId, viewModel = null) { this.routes.set(name, { componentClass, containerId, viewModel }); // Pre-initialize component in cache for better performance this.preInitializeComponent(name, componentClass, containerId, viewModel); } // Pre-initialize component in cache preInitializeComponent(name, componentClass, containerId, viewModel) { const container = document.getElementById(containerId); if (!container) return; // Create component instance but don't mount it yet const component = new componentClass(container, viewModel, this.eventBus); component.routeName = name; component.isCached = true; // Store in cache this.componentCache.set(name, component); console.log(`App: Pre-initialized component for route '${name}'`); } // Navigate to a route navigateTo(routeName) { // Check cooldown period const now = Date.now(); if (now - this.lastNavigationTime < this.navigationCooldown) { console.log(`App: Navigation cooldown active, skipping route '${routeName}'`); return; } // If navigation is already in progress, queue this request if (this.navigationInProgress) { console.log(`App: Navigation in progress, queuing route '${routeName}'`); if (!this.navigationQueue.includes(routeName)) { this.navigationQueue.push(routeName); } return; } // If trying to navigate to the same route, do nothing if (this.currentView && this.currentView.routeName === routeName) { console.log(`App: Already on route '${routeName}', skipping navigation`); return; } this.lastNavigationTime = now; this.performNavigation(routeName); } // Perform the actual navigation async performNavigation(routeName) { this.navigationInProgress = true; try { console.log(`App: Navigating to route '${routeName}'`); const route = this.routes.get(routeName); if (!route) { console.error(`Route '${routeName}' not found`); return; } console.log(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`); // Get or create component from cache let component = this.componentCache.get(routeName); if (!component) { console.log(`App: Component not in cache, creating new instance for '${routeName}'`); const container = document.getElementById(route.containerId); if (!container) { console.error(`Container '${route.containerId}' not found`); return; } component = new route.componentClass(container, route.viewModel, this.eventBus); component.routeName = routeName; component.isCached = true; this.componentCache.set(routeName, component); } // Hide current view smoothly if (this.currentView) { console.log('App: Hiding current view'); await this.hideCurrentView(); } // Show new view console.log(`App: Showing new view '${routeName}'`); await this.showView(routeName, component); // Update navigation state this.updateNavigation(routeName); // Set as current view this.currentView = component; // Mark view as cached for future use this.cachedViews.add(routeName); console.log(`App: Navigation to '${routeName}' completed`); } catch (error) { console.error('App: Navigation failed:', error); } finally { this.navigationInProgress = false; // Process any queued navigation requests if (this.navigationQueue.length > 0) { const nextRoute = this.navigationQueue.shift(); console.log(`App: Processing queued navigation to '${nextRoute}'`); setTimeout(() => this.navigateTo(nextRoute), 100); } } } // Hide current view smoothly async hideCurrentView() { if (!this.currentView) return; // If component is mounted, pause it instead of unmounting if (this.currentView.isMounted) { console.log('App: Pausing current view instead of unmounting'); this.currentView.pause(); } // Fade out the container if (this.currentView.container) { this.currentView.container.style.opacity = '0'; this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`; } // Wait for fade out to complete await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150)); } // Show view smoothly async showView(routeName, component) { const container = component.container; // Ensure component is mounted (but not necessarily active) if (!component.isMounted) { console.log(`App: Mounting component for '${routeName}'`); component.mount(); } else { console.log(`App: Resuming component for '${routeName}'`); component.resume(); } // Fade in the container container.style.opacity = '0'; container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`; // Small delay to ensure smooth transition await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50)); // Fade in container.style.opacity = '1'; } // Update navigation state updateNavigation(activeRoute) { // Remove active class from all nav tabs document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => { tab.classList.remove('active'); }); // Add active class to current route tab const activeTab = document.querySelector(`[data-view="${activeRoute}"]`); if (activeTab) { activeTab.classList.add('active'); } // Hide all view contents with smooth transition document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => { view.classList.remove('active'); view.style.opacity = '0'; view.style.transition = 'opacity 0.15s ease-out'; }); // Show current view content with smooth transition const activeView = document.getElementById(`${activeRoute}-view`); if (activeView) { activeView.classList.add('active'); // Small delay to ensure smooth transition setTimeout(() => { activeView.style.opacity = '1'; activeView.style.transition = 'opacity 0.2s ease-in'; }, 50); } } // Register a component registerComponent(name, component) { this.components.set(name, component); } // Get a component by name getComponent(name) { return this.components.get(name); } // Get the event bus getEventBus() { return this.eventBus; } // Initialize the application init() { console.log('SPORE UI Framework initialized'); // Note: Navigation is now handled by the app initialization // to ensure routes are registered before navigation } // Setup navigation setupNavigation() { document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => { tab.addEventListener('click', () => { const routeName = tab.dataset.view; this.navigateTo(routeName); }); }); } // Clean up cached components (call when app is shutting down) cleanup() { console.log('App: Cleaning up cached components...'); this.componentCache.forEach((component, routeName) => { if (component.isMounted) { console.log(`App: Unmounting cached component '${routeName}'`); component.unmount(); } }); this.componentCache.clear(); this.cachedViews.clear(); } } // Global app instance window.app = new App();