766 lines
26 KiB
JavaScript
766 lines
26 KiB
JavaScript
/**
|
|
* 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<LoadingContext>}
|
|
*/
|
|
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 = `
|
|
<span class="loading-spinner spinner-border spinner-border-sm"></span>
|
|
<span class="loading-text">${this.options.text}</span>
|
|
`;
|
|
break;
|
|
|
|
case 'skeleton':
|
|
element.innerHTML = this.createSkeletonHTML();
|
|
break;
|
|
|
|
case 'inline':
|
|
element.innerHTML = `
|
|
<div class="loading-inline">
|
|
<span class="loading-spinner"></span>
|
|
<span class="loading-text">${this.options.text}</span>
|
|
</div>
|
|
`;
|
|
break;
|
|
|
|
case 'overlay':
|
|
default:
|
|
element.innerHTML = `
|
|
<div class="loading-overlay">
|
|
<div class="loading-content">
|
|
<div class="loading-spinner"></div>
|
|
${this.options.showProgress ? '<div class="progress-bar"><div class="progress-fill"></div></div>' : ''}
|
|
<div class="loading-text">${this.options.text}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
break;
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
createSkeletonHTML() {
|
|
// Create skeleton based on element content
|
|
return `
|
|
<div class="skeleton-container">
|
|
<div class="skeleton skeleton-card">
|
|
<div class="skeleton-header">
|
|
<div class="skeleton-circle"></div>
|
|
<div class="skeleton-content">
|
|
<div class="skeleton-line"></div>
|
|
<div class="skeleton-line skeleton-line--short"></div>
|
|
</div>
|
|
</div>
|
|
<div class="skeleton-body">
|
|
<div class="skeleton-line"></div>
|
|
<div class="skeleton-line"></div>
|
|
<div class="skeleton-line skeleton-line--short"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
showButtonLoading(button) {
|
|
// Store original content
|
|
const originalHTML = button.innerHTML;
|
|
this.loadingElements.set(button, { originalHTML });
|
|
|
|
// Add loading spinner to button
|
|
const loadingContent = `
|
|
<span class="loading-spinner spinner-border spinner-border-sm" aria-hidden="true"></span>
|
|
<span class="visually-hidden">Loading...</span>
|
|
`;
|
|
|
|
// 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 '<div class="skeleton skeleton-circle"></div>';
|
|
case 'rectangle':
|
|
return '<div class="skeleton skeleton-rectangle"></div>';
|
|
case 'text':
|
|
return '<div class="skeleton skeleton-line"></div>';
|
|
case 'title':
|
|
return '<div class="skeleton skeleton-line skeleton-line--title"></div>';
|
|
default:
|
|
return '<div class="skeleton skeleton-line"></div>';
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="loading-content">
|
|
<div class="loading-spinner"></div>
|
|
<div class="loading-text">${text}</div>
|
|
</div>
|
|
`;
|
|
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');
|
|
|
|
})();
|