Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
257 lines
8.0 KiB
JavaScript
257 lines
8.0 KiB
JavaScript
/**
|
|
* Action Loading Manager for LiveComponents
|
|
*
|
|
* Provides skeleton loading states during component actions with:
|
|
* - Automatic skeleton overlay during actions
|
|
* - Configurable skeleton templates per component
|
|
* - Smooth transitions
|
|
* - Fragment-aware loading
|
|
*/
|
|
|
|
export class ActionLoadingManager {
|
|
constructor() {
|
|
this.loadingStates = new Map(); // componentId → loading state
|
|
this.skeletonTemplates = new Map(); // componentId → template
|
|
this.config = {
|
|
showDelay: 150, // ms before showing skeleton
|
|
transitionDuration: 200, // ms for transitions
|
|
preserveContent: true, // Keep content visible under skeleton
|
|
opacity: 0.6 // Skeleton overlay opacity
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show loading state for component
|
|
*
|
|
* @param {string} componentId - Component ID
|
|
* @param {HTMLElement} element - Component element
|
|
* @param {Object} options - Loading options
|
|
*/
|
|
showLoading(componentId, element, options = {}) {
|
|
// Check if already loading
|
|
if (this.loadingStates.has(componentId)) {
|
|
return; // Already showing loading state
|
|
}
|
|
|
|
const showDelay = options.showDelay ?? this.config.showDelay;
|
|
const fragments = options.fragments || null;
|
|
|
|
// Delay showing skeleton (for fast responses)
|
|
const timeoutId = setTimeout(() => {
|
|
this.createSkeletonOverlay(componentId, element, fragments, options);
|
|
}, showDelay);
|
|
|
|
// Store loading state
|
|
this.loadingStates.set(componentId, {
|
|
timeoutId,
|
|
element,
|
|
fragments,
|
|
options
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create skeleton overlay
|
|
*
|
|
* @param {string} componentId - Component ID
|
|
* @param {HTMLElement} element - Component element
|
|
* @param {Array<string>|null} fragments - Fragment names to show skeleton for
|
|
* @param {Object} options - Options
|
|
*/
|
|
createSkeletonOverlay(componentId, element, fragments, options) {
|
|
// Get skeleton template
|
|
const template = this.getSkeletonTemplate(componentId, element, fragments);
|
|
|
|
// Create overlay container
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'livecomponent-loading-overlay';
|
|
overlay.setAttribute('data-component-id', componentId);
|
|
overlay.setAttribute('aria-busy', 'true');
|
|
overlay.setAttribute('aria-label', 'Loading...');
|
|
|
|
overlay.style.cssText = `
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, ${this.config.opacity});
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transition: opacity ${this.config.transitionDuration}ms ease;
|
|
pointer-events: none;
|
|
`;
|
|
|
|
// Add skeleton content
|
|
overlay.innerHTML = template;
|
|
|
|
// Ensure element has relative positioning
|
|
const originalPosition = element.style.position;
|
|
if (getComputedStyle(element).position === 'static') {
|
|
element.style.position = 'relative';
|
|
}
|
|
|
|
// Append overlay
|
|
element.appendChild(overlay);
|
|
|
|
// Animate in
|
|
requestAnimationFrame(() => {
|
|
overlay.style.opacity = '1';
|
|
});
|
|
|
|
// Update loading state
|
|
const state = this.loadingStates.get(componentId);
|
|
if (state) {
|
|
state.overlay = overlay;
|
|
state.originalPosition = originalPosition;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get skeleton template for component
|
|
*
|
|
* @param {string} componentId - Component ID
|
|
* @param {HTMLElement} element - Component element
|
|
* @param {Array<string>|null} fragments - Fragment names
|
|
* @returns {string} Skeleton HTML
|
|
*/
|
|
getSkeletonTemplate(componentId, element, fragments) {
|
|
// Check for custom template
|
|
const customTemplate = this.skeletonTemplates.get(componentId);
|
|
if (customTemplate) {
|
|
return typeof customTemplate === 'function'
|
|
? customTemplate(element, fragments)
|
|
: customTemplate;
|
|
}
|
|
|
|
// If fragments specified, create fragment-specific skeletons
|
|
if (fragments && fragments.length > 0) {
|
|
return this.createFragmentSkeletons(fragments);
|
|
}
|
|
|
|
// Default skeleton template
|
|
return this.createDefaultSkeleton(element);
|
|
}
|
|
|
|
/**
|
|
* Create default skeleton template
|
|
*
|
|
* @param {HTMLElement} element - Component element
|
|
* @returns {string} Skeleton HTML
|
|
*/
|
|
createDefaultSkeleton(element) {
|
|
const height = element.offsetHeight || 200;
|
|
const width = element.offsetWidth || '100%';
|
|
|
|
return `
|
|
<div class="skeleton-container" style="width: ${width}; height: ${height}px; padding: 1.5rem;">
|
|
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
|
|
<div class="skeleton skeleton-text skeleton-text--80" style="margin-bottom: 0.75rem;"></div>
|
|
<div class="skeleton skeleton-text skeleton-text--60" style="margin-bottom: 0.75rem;"></div>
|
|
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
|
|
<div class="skeleton skeleton-text skeleton-text--80"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Create fragment-specific skeletons
|
|
*
|
|
* @param {Array<string>} fragments - Fragment names
|
|
* @returns {string} Skeleton HTML
|
|
*/
|
|
createFragmentSkeletons(fragments) {
|
|
return fragments.map(fragmentName => `
|
|
<div class="skeleton-fragment" data-fragment="${fragmentName}">
|
|
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 0.75rem;"></div>
|
|
<div class="skeleton skeleton-text skeleton-text--80"></div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
/**
|
|
* Hide loading state
|
|
*
|
|
* @param {string} componentId - Component ID
|
|
*/
|
|
hideLoading(componentId) {
|
|
const state = this.loadingStates.get(componentId);
|
|
if (!state) {
|
|
return;
|
|
}
|
|
|
|
// Clear timeout if not yet shown
|
|
if (state.timeoutId) {
|
|
clearTimeout(state.timeoutId);
|
|
}
|
|
|
|
// Remove overlay if exists
|
|
if (state.overlay) {
|
|
// Animate out
|
|
state.overlay.style.opacity = '0';
|
|
|
|
setTimeout(() => {
|
|
if (state.overlay && state.overlay.parentNode) {
|
|
state.overlay.parentNode.removeChild(state.overlay);
|
|
}
|
|
}, this.config.transitionDuration);
|
|
}
|
|
|
|
// Restore original position
|
|
if (state.originalPosition !== undefined) {
|
|
state.element.style.position = state.originalPosition;
|
|
}
|
|
|
|
// Remove from map
|
|
this.loadingStates.delete(componentId);
|
|
}
|
|
|
|
/**
|
|
* Register custom skeleton template for component
|
|
*
|
|
* @param {string} componentId - Component ID
|
|
* @param {string|Function} template - Template HTML or function that returns HTML
|
|
*/
|
|
registerTemplate(componentId, template) {
|
|
this.skeletonTemplates.set(componentId, template);
|
|
}
|
|
|
|
/**
|
|
* Unregister skeleton template
|
|
*
|
|
* @param {string} componentId - Component ID
|
|
*/
|
|
unregisterTemplate(componentId) {
|
|
this.skeletonTemplates.delete(componentId);
|
|
}
|
|
|
|
/**
|
|
* Check if component is loading
|
|
*
|
|
* @param {string} componentId - Component ID
|
|
* @returns {boolean} True if loading
|
|
*/
|
|
isLoading(componentId) {
|
|
return this.loadingStates.has(componentId);
|
|
}
|
|
|
|
/**
|
|
* Update configuration
|
|
*
|
|
* @param {Object} newConfig - New configuration
|
|
*/
|
|
updateConfig(newConfig) {
|
|
this.config = {
|
|
...this.config,
|
|
...newConfig
|
|
};
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const actionLoadingManager = new ActionLoadingManager();
|
|
|