/** * Loading States Manager * Manages loading indicators, progress bars, and loading hierarchies throughout the UI * Provides comprehensive loading state tracking for async operations */ (function() { 'use strict'; AppModules.require('notifications'); AppModules.register('loading-states'); // Loading state tracking const loadingStates = new Map(); const loadingHierarchy = new Map(); // Parent-child relationships const loadingQueue = []; // Configuration const config = { maxConcurrentLoadings: 5, defaultFadeDelay: 300, skeletonTransitionDuration: 200, progressUpdateInterval: 50 }; // Loading state manager class class LoadingStateManager { constructor() { this.activeLoadings = new Set(); this.loadingElements = new WeakMap(); this.progressTrackers = new Map(); } /** * Start a loading operation * @param {string} id - Unique loading identifier * @param {Object} options - Loading options * @returns {LoadingContext} Loading context for management */ start(id, options = {}) { const context = new LoadingContext(id, options); this.activeLoadings.add(id); loadingStates.set(id, context); context.start(); // Check queue if at capacity if (this.activeLoadings.size >= config.maxConcurrentLoadings) { this.processQueue(); } this.log('Started loading:', id); return context; } /** * End a loading operation * @param {string} id - Loading identifier * @param {boolean} success - Whether loading was successful */ end(id, success = true) { const context = loadingStates.get(id); if (!context) return; context.end(success); this.activeLoadings.delete(id); loadingStates.delete(id); // Process next in queue this.processQueue(); this.log('Ended loading:', id, success ? 'success' : 'failed'); } /** * Update loading progress * @param {string} id - Loading identifier * @param {number} progress - Progress (0-100) */ updateProgress(id, progress) { const context = loadingStates.get(id); if (context) { context.updateProgress(progress); } } /** * Process loading queue */ processQueue() { while (this.activeLoadings.size < config.maxConcurrentLoadings && loadingQueue.length > 0) { const queuedItem = loadingQueue.shift(); queuedItem.resolve(); } } /** * Add to loading hierarchy * @param {string} parentId - Parent loading ID * @param {string} childId - Child loading ID */ addToHierarchy(parentId, childId) { if (!loadingHierarchy.has(parentId)) { loadingHierarchy.set(parentId, new Set()); } loadingHierarchy.get(parentId).add(childId); } /** * Get loading context by ID * @param {string} id - Loading identifier * @returns {LoadingContext|null} */ get(id) { return loadingStates.get(id) || null; } /** * Get all active loading contexts * @returns {Array} */ getAllActive() { return Array.from(this.activeLoadings).map(id => loadingStates.get(id)); } /** * Check if any loading is active * @returns {boolean} */ isAnyLoading() { return this.activeLoadings.size > 0; } /** * Check if specific loading is active * @param {string} id - Loading identifier * @returns {boolean} */ isLoading(id) { return this.activeLoadings.has(id); } log(...args) { if (window.AppConfig?.debug?.loadingStates) { console.log('[LoadingStates]', ...args); } } } // Loading context class for individual loading operations class LoadingContext { constructor(id, options = {}) { this.id = id; this.options = { type: options.type || 'overlay', // overlay, button, inline, skeleton element: options.element || null, text: options.text || 'Loading...', showProgress: options.showProgress || false, cancellable: options.cancellable || false, timeout: options.timeout || 30000, // Default 30 seconds hierarchy: options.hierarchy || null, ...options }; this.startTime = null; this.endTime = null; this.progress = 0; this.isActive = false; this.timeoutId = null; // Create loading element if needed if (!this.options.element) { this.options.element = this.createLoadingElement(); } } start() { this.startTime = Date.now(); this.isActive = true; // Show loading UI this.show(); // Set timeout fail-safe if (this.options.timeout > 0) { this.timeoutId = setTimeout(() => { if (this.isActive) { this.end(false); AppLogger.warn(`Loading operation ${this.id} timed out after ${this.options.timeout}ms`); } }, this.options.timeout); } // Add to hierarchy if specified if (this.options.hierarchy) { loadingStateManager.addToHierarchy(this.options.hierarchy, this.id); } } end(success = true) { if (!this.isActive) return; this.endTime = Date.now(); this.isActive = false; // Clear timeout if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; } // Hide loading UI this.hide(success); // Log duration const duration = this.endTime - this.startTime; loadingStateManager.log(`Loading ${this.id} completed in ${duration}ms`); } updateProgress(progress) { this.progress = Math.max(0, Math.min(100, progress)); this.updateProgressUI(); // Trigger progress event this.trigger('progress', { progress: this.progress }); } show() { const element = this.options.element; if (!element) return; // Add loading class element.classList.add('loading'); // Handle different loading types switch (this.options.type) { case 'button': this.showButtonLoading(element); break; case 'skeleton': this.showSkeletonLoading(element); break; case 'inline': this.showInlineLoading(element); break; case 'overlay': default: this.showOverlayLoading(element); break; } // Add to DOM if not already present if (!element.parentElement && element !== document.body) { element.style.display = 'block'; } // Trigger show event this.trigger('show'); } hide(success = true) { const element = this.options.element; if (!element) return; // Handle different loading types switch (this.options.type) { case 'button': this.hideButtonLoading(element, success); break; case 'skeleton': this.hideSkeletonLoading(element, success); break; case 'inline': this.hideInlineLoading(element, success); break; case 'overlay': default: this.hideOverlayLoading(element, success); break; } // Remove loading class after transition setTimeout(() => { element.classList.remove('loading'); }, config.defaultFadeDelay); // Trigger hide event this.trigger('hide', { success }); } createLoadingElement() { const element = document.createElement('div'); element.className = `loading-state loading-state--${this.options.type}`; element.setAttribute('data-loading-id', this.id); element.setAttribute('aria-live', 'polite'); element.setAttribute('aria-label', this.options.text); switch (this.options.type) { case 'button': element.innerHTML = ` ${this.options.text} `; break; case 'skeleton': element.innerHTML = this.createSkeletonHTML(); break; case 'inline': element.innerHTML = `
${this.options.text}
`; break; case 'overlay': default: element.innerHTML = `
${this.options.showProgress ? '
' : ''}
${this.options.text}
`; break; } return element; } createSkeletonHTML() { // Create skeleton based on element content return `
`; } showButtonLoading(button) { // Store original content const originalHTML = button.innerHTML; this.loadingElements.set(button, { originalHTML }); // Add loading spinner to button const loadingContent = ` Loading... `; // Keep button text but add spinner const textSpan = button.querySelector('.loading-text') || document.createElement('span'); textSpan.className = 'loading-text'; textSpan.textContent = this.options.text; button.innerHTML = loadingContent + textSpan.outerHTML; button.disabled = true; button.setAttribute('aria-busy', 'true'); } hideButtonLoading(button, success) { const stored = this.loadingElements.get(button); if (stored) { // Restore original content or keep success state setTimeout(() => { button.innerHTML = stored.originalHTML; button.disabled = false; button.removeAttribute('aria-busy'); if (success) { button.classList.add('btn-success-pulse'); setTimeout(() => button.classList.remove('btn-success-pulse'), 1000); } }, config.defaultFadeDelay); } } showSkeletonLoading(element) { // Add skeleton overlay element.classList.add('skeleton-loading'); // Find content areas and replace with skeletons const contentAreas = element.querySelectorAll('[data-skeleton]'); contentAreas.forEach(area => { const skeletonType = area.dataset.skeleton || 'line'; area.innerHTML = this.createSkeletonForType(skeletonType); }); // If no skeleton areas defined, add general skeleton if (contentAreas.length === 0) { element.innerHTML = this.createSkeletonHTML(); } } hideSkeletonLoading(element, success) { element.classList.remove('skeleton-loading'); // Fade out skeletons const skeletons = element.querySelectorAll('.skeleton'); skeletons.forEach(skeleton => { skeleton.classList.add('skeleton-exit'); setTimeout(() => skeleton.remove(), config.skeletonTransitionDuration); }); } showOverlayLoading(element) { // Position overlay const overlay = element.querySelector('.loading-overlay'); if (overlay) { overlay.style.display = 'flex'; element.classList.add('has-loading-overlay'); } } hideOverlayLoading(element, success) { const overlay = element.querySelector('.loading-overlay'); if (overlay) { overlay.style.display = 'none'; element.classList.remove('has-loading-overlay'); } } showInlineLoading(element) { // Just show the inline loading content element.style.display = 'inline-block'; } hideInlineLoading(element, success) { element.style.display = 'none'; } updateProgressUI() { if (!this.options.showProgress) return; const progressFill = this.options.element.querySelector('.progress-fill'); if (progressFill) { progressFill.style.width = `${this.progress}%`; } const progressText = this.options.element.querySelector('.progress-text'); if (progressText) { progressText.textContent = `${Math.round(this.progress)}%`; } } createSkeletonForType(type) { switch (type) { case 'circle': return '
'; case 'rectangle': return '
'; case 'text': return '
'; case 'title': return '
'; default: return '
'; } } trigger(eventName, data = {}) { // Trigger loading events const event = new CustomEvent(`loading:${eventName}`, { detail: { id: this.id, context: this, ...data } }); document.dispatchEvent(event); } } // Global loading state manager instance const loadingStateManager = new LoadingStateManager(); // API Functions const API = { /** * Start a loading operation */ startLoading(id, options) { return loadingStateManager.start(id, options); }, /** * End a loading operation */ endLoading(id, success = true) { loadingStateManager.end(id, success); }, /** * Update loading progress */ updateProgress(id, progress) { loadingStateManager.updateProgress(id, progress); }, /** * Check if loading is active */ isLoading(id) { return loadingStateManager.isLoading(id); }, /** * Check if any loading is active */ isAnyLoading() { return loadingStateManager.isAnyLoading(); }, /** * Create button loading state */ showButtonLoading(button, text = 'Loading...') { const loadingId = `button-${Date.now()}-${Math.random()}`; const context = loadingStateManager.start(loadingId, { type: 'button', element: button, text: text, showProgress: false }); button.setAttribute('data-loading-id', loadingId); return context; }, /** * Show page-level loading overlay */ showPageLoading(text = 'Loading...') { return loadingStateManager.start('page-loading', { type: 'overlay', element: document.body, text: text, hierarchy: 'global' }); }, /** * Show section loading with skeleton */ showSectionLoading(element, text = 'Loading...') { const loadingId = `section-${Date.now()}`; return loadingStateManager.start(loadingId, { type: 'skeleton', element: element, text: text }); }, /** * Show content loading overlay */ showContentLoading(container, text = 'Loading...') { const loadingId = `content-${Date.now()}`; const overlay = document.createElement('div'); overlay.className = 'loading-overlay'; overlay.innerHTML = `
${text}
`; container.appendChild(overlay); container.classList.add('has-loading-overlay'); return loadingStateManager.start(loadingId, { type: 'overlay', element: container, text: text, customOverlay: true }); }, /** * Show video loading progress */ showVideoLoading(progress = null) { return loadingStateManager.start('video-loading', { type: 'overlay', element: document.getElementById('videoSection'), text: 'Loading video...', showProgress: progress !== null }); }, /** * Show chat refresh loading */ showChatRefreshLoading() { const chatSection = document.getElementById('chatSection'); return API.showSectionLoading(chatSection, 'Refreshing chat...'); }, /** * Create loading queue for sequential operations */ createLoadingQueue() { return { queue: [], add(operation, options = {}) { const promise = new Promise((resolve, reject) => { this.queue.push({ operation, options, resolve, reject }); }); return promise; }, process(concurrency = 1) { return Promise.all( Array.from({ length: concurrency }, () => this.processNext()) ); }, async processNext() { if (this.queue.length === 0) return; const item = this.queue.shift(); try { const result = await item.operation(); item.resolve(result); return result; } catch (error) { item.reject(error); throw error; } } }; } }; // Export API window.LoadingStates = API; // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { // Add global loading state CSS if not present if (!document.getElementById('loading-states-styles')) { const style = document.createElement('style'); style.id = 'loading-states-styles'; style.textContent = ` /* Loading States Styles */ .loading-state { position: relative; } .loading-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(255, 255, 255, 0.9); backdrop-filter: blur(2px); display: none; justify-content: center; align-items: center; z-index: 1000; transition: opacity 0.3s ease; } .loading-content { text-align: center; padding: 1rem; } .loading-text { margin-top: 0.5rem; font-weight: 500; color: var(--dodgers-blue); } .has-loading-overlay { position: relative; } .skeleton-loading .skeleton { animation: skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } .skeleton-loading .skeleton-exit { opacity: 0; transition: opacity 0.2s ease; } .btn-success-pulse { animation: pulse 0.5s ease-in-out; background-color: var(--dodgers-blue) !important; } .loading-inline { display: inline-flex; align-items: center; gap: 0.5rem; } /* Progress bar in loading states */ .loading-content .progress-bar { margin-top: 1rem; width: 200px; height: 4px; background-color: var(--dodgers-gray-200); border-radius: 2px; overflow: hidden; } .loading-content .progress-fill { height: 100%; background: linear-gradient(90deg, var(--dodgers-blue), var(--dodgers-red)); width: 0%; transition: width 0.3s ease; } /* Mobile optimizations */ @media (max-width: 767px) { .loading-overlay { padding: 0.5rem; } .loading-content .progress-bar { width: 150px; } } /* Accessibility */ .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } `; document.head.appendChild(style); } AppLogger.log('Loading States module initialized'); }); AppLogger.log('Loading States module loaded'); })();