// 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) { logger.debug(`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; logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`); this._notifyListeners(property, value, this._previousData[property]); } else { logger.debug(`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) { logger.debug(`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) { logger.debug(`ViewModel: _notifyListeners called for property '${property}'`); if (this._listeners.has(property)) { const callbacks = this._listeners.get(property); logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`); callbacks.forEach((callback, index) => { try { logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`); callback(value, previousValue); } catch (error) { console.error(`Error in property listener for ${property}:`, error); } }); } else { logger.debug(`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 { notifyChanges = true } = options; // 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; } }); // Notify listeners for changed keys if (notifyChanges) { changedKeys.forEach(key => { this._notifyListeners(key, this._data[key], this._previousData[key]); }); } } } // Base Component class 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; logger.debug(`${this.constructor.name}: Starting mount...`); this.isMounted = true; this.setupEventListeners(); this.setupViewModelListeners(); this.render(); logger.debug(`${this.constructor.name}: Mounted successfully`); } // Unmount the component unmount() { if (!this.isMounted) return; this.isMounted = false; this.cleanupEventListeners(); this.cleanupViewModelListeners(); logger.debug(`${this.constructor.name} unmounted`); } // Pause the component (keep alive but pause activity) pause() { if (!this.isMounted) return; logger.debug(`${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; logger.debug(`${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 logger.debug(`${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) { logger.debug(`${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; logger.debug(`${this.constructor.name}: Using component container for empty selector`); } else { // Find element within the component's container element = this.findElement(selector); } if (element) { logger.debug(`${this.constructor.name}: Element found, setting innerHTML`); element.innerHTML = html; logger.debug(`${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 || `