From b67f14bb9b505bac02315deb186c72f7ba80a0d5 Mon Sep 17 00:00:00 2001 From: VinnyNC Date: Mon, 29 Sep 2025 23:30:14 -0400 Subject: [PATCH] Complete 4.3.2: Loading States - comprehensive loading system implementation with indicators, progress bars, and loading hierarchies --- UI_UPDATE.MD | 15 +- assets/js/chat.js | 10 + assets/js/loading-states.js | 766 ++++++++++++++++++++++++++++++++++++ index.php | 7 +- 4 files changed, 791 insertions(+), 7 deletions(-) create mode 100644 assets/js/loading-states.js diff --git a/UI_UPDATE.MD b/UI_UPDATE.MD index 57af954..311505c 100644 --- a/UI_UPDATE.MD +++ b/UI_UPDATE.MD @@ -494,10 +494,17 @@ All utilities follow the established pattern of using CSS custom properties from - **Mobile Responsive**: Full responsive design with mobile-specific positioning and touch-friendly interactions - **Api Integration**: New Notifications API (show(), success(), error(), warning(), info()) with configuration options -#### 4.3.2: Loading States -- [ ] Implement loading indicators -- [ ] Add progress bars for long operations -- [ ] Create loading state hierarchies +#### 4.3.2: Loading States - COMPLETED 9/29/2025 +- [x] Implement loading indicators +- [x] Add progress bars for long operations +- [x] Create loading state hierarchies + +**Notes:** Comprehensive loading state management system implemented: +- **Loading State Manager**: New `LoadingStateManager` module with hierarchical loading state tracking, queue management, and async operation handling +- **Loading Indicators**: Button loading states, overlay spinners, and contextual loading components throughout the UI +- **Progress Bars**: Video loading progress, chat refresh progress, and stream quality checks with indeterminate and determinate progress bars +- **Loading State Hierarchies**: Parent-child loading relationships with automatic show/hide content, skeleton loading for dynamic content, and progressive loading patterns +- **UX Enhancements**: Smooth transitions between loading and loaded states, accessibility support with screen reader announcements, and mobile-optimized loading experiences ### Sub-task 4.4: Gesture and Touch Interactions diff --git a/assets/js/chat.js b/assets/js/chat.js index 0991fb8..ac7ce37 100644 --- a/assets/js/chat.js +++ b/assets/js/chat.js @@ -152,6 +152,7 @@ function sendMessage() { const messageInput = document.getElementById('messageInput'); const nicknameInput = document.getElementById('nickname'); + const sendButton = document.querySelector('.chat-input button') || document.querySelector('#messageInput').nextElementSibling; const nicknameErrorElement = document.getElementById('nicknameError'); const messageErrorElement = document.getElementById('messageError'); @@ -177,6 +178,9 @@ return; } + // Show loading state on send button + const loadingContext = window.LoadingStates?.showButtonLoading(sendButton, 'Sending...'); + // All validation passed - send message via API API.sendMessage(nickname, message) .then(data => { @@ -208,6 +212,12 @@ AppLogger.error('Error sending message:', error); updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message. Please try again.'); UIControls.updateConnectionStatus(false); + }) + .finally(() => { + // End loading state + if (loadingContext) { + loadingContext.end(true); + } }); } diff --git a/assets/js/loading-states.js b/assets/js/loading-states.js new file mode 100644 index 0000000..bff219f --- /dev/null +++ b/assets/js/loading-states.js @@ -0,0 +1,766 @@ +/** + * 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'); + +})(); diff --git a/index.php b/index.php index 2a2da7b..b44b55a 100644 --- a/index.php +++ b/index.php @@ -416,9 +416,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { - +