Files
spore-ui/public/framework.js
2025-08-28 10:21:14 +02:00

785 lines
25 KiB
JavaScript

// SPORE UI Framework - Component-based architecture with pub/sub system
// 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 = {};
const unchangedProperties = {};
Object.keys(properties).forEach(key => {
if (this._data[key] !== properties[key]) {
changedProperties[key] = properties[key];
} else {
unchangedProperties[key] = properties[key];
}
});
// Set all properties
Object.keys(properties).forEach(key => {
this._data[key] = properties[key];
});
// Notify listeners only for changed properties
Object.keys(changedProperties).forEach(key => {
this._notifyListeners(key, changedProperties[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;
if (preserveUIState) {
// Store current UI state
const currentUIState = new Map(this._uiState);
// Apply updates
Object.keys(updates).forEach(key => {
this._data[key] = updates[key];
});
// Restore UI state
this._uiState = currentUIState;
} else {
// Apply updates normally
Object.keys(updates).forEach(key => {
this._data[key] = updates[key];
});
}
// Notify listeners if requested
if (notifyChanges) {
Object.keys(updates).forEach(key => {
this._notifyListeners(key, updates[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;
}
}
}
// 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 = 300; // 300ms 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 0.15s ease-out';
}
// Wait for fade out to complete
await new Promise(resolve => setTimeout(resolve, 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 0.2s ease-in';
// Small delay to ensure smooth transition
await new Promise(resolve => setTimeout(resolve, 50));
// Fade in
container.style.opacity = '1';
}
// Update navigation state
updateNavigation(activeRoute) {
// Remove active class from all nav tabs
document.querySelectorAll('.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('.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('.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();