Files
michaelschiemer/resources/js/modules/admin/ui-enhancements.js
2025-11-24 21:28:25 +01:00

254 lines
8.4 KiB
JavaScript

/**
* UI Enhancements Module
*
* Provides loading states, optimistic updates, and better error handling
*/
export class UIEnhancements {
constructor() {
this.init();
}
init() {
this.setupLoadingStates();
this.setupOptimisticUpdates();
this.setupErrorHandling();
this.setupFormEnhancements();
}
/**
* Setup loading states for async operations
*/
setupLoadingStates() {
// Add loading state to buttons
document.addEventListener('click', (e) => {
const button = e.target.closest('button[data-loading]');
if (button && !button.disabled) {
const originalText = button.textContent;
button.dataset.originalText = originalText;
button.disabled = true;
button.classList.add('loading');
// Add spinner
const spinner = document.createElement('span');
spinner.className = 'button-spinner';
spinner.innerHTML = '⏳';
button.prepend(spinner);
button.textContent = button.dataset.loadingText || 'Loading...';
// Auto-reset after 10 seconds (safety)
setTimeout(() => {
this.resetButton(button);
}, 10000);
}
});
}
resetButton(button) {
if (button.dataset.originalText) {
button.textContent = button.dataset.originalText;
delete button.dataset.originalText;
}
button.disabled = false;
button.classList.remove('loading');
const spinner = button.querySelector('.button-spinner');
if (spinner) {
spinner.remove();
}
}
/**
* Setup optimistic updates for better UX
*/
setupOptimisticUpdates() {
// Optimistic update helper
window.optimisticUpdate = (element, updateFn, rollbackFn) => {
const originalState = this.captureState(element);
try {
updateFn(element);
return () => {
// Rollback function
this.restoreState(element, originalState);
if (rollbackFn) rollbackFn();
};
} catch (error) {
console.error('Optimistic update failed:', error);
return null;
}
};
// Apply optimistic updates to table rows
document.addEventListener('click', (e) => {
const row = e.target.closest('tr[data-id]');
if (!row) return;
const action = e.target.closest('[data-optimistic]');
if (!action) return;
const actionType = action.dataset.optimistic;
const rollback = this.optimisticTableUpdate(row, actionType);
// Store rollback function
action.dataset.rollback = 'true';
action.addEventListener('error', () => {
if (rollback) rollback();
}, { once: true });
});
}
optimisticTableUpdate(row, actionType) {
const originalClass = row.className;
const originalOpacity = row.style.opacity;
switch (actionType) {
case 'delete':
row.style.opacity = '0.5';
row.style.textDecoration = 'line-through';
row.classList.add('optimistic-delete');
return () => {
row.className = originalClass;
row.style.opacity = originalOpacity;
row.style.textDecoration = '';
row.classList.remove('optimistic-delete');
};
case 'update':
row.classList.add('optimistic-update');
return () => {
row.classList.remove('optimistic-update');
};
default:
return null;
}
}
captureState(element) {
return {
className: element.className,
style: element.style.cssText,
innerHTML: element.innerHTML,
};
}
restoreState(element, state) {
element.className = state.className;
element.style.cssText = state.style;
element.innerHTML = state.innerHTML;
}
/**
* Setup better error handling
*/
setupErrorHandling() {
// Global error handler
window.addEventListener('error', (e) => {
this.showErrorNotification('An unexpected error occurred', e.error);
});
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (e) => {
this.showErrorNotification('Request failed', e.reason);
});
// Enhanced fetch wrapper with error handling
window.safeFetch = async (url, options = {}) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
this.showErrorNotification('Request failed', error.message);
throw error;
}
};
}
showErrorNotification(title, message) {
// Use existing toast system if available
if (window.Toast) {
window.Toast.error(`${title}: ${message}`);
} else {
// Fallback: console error
console.error(title, message);
// Show simple alert as last resort
const errorDiv = document.createElement('div');
errorDiv.className = 'error-notification';
errorDiv.innerHTML = `
<strong>${title}</strong>
<p>${message}</p>
<button onclick="this.parentElement.remove()">Close</button>
`;
document.body.appendChild(errorDiv);
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.remove();
}
}, 5000);
}
}
/**
* Setup form enhancements
*/
setupFormEnhancements() {
// Add loading state to forms
document.addEventListener('submit', (e) => {
const form = e.target;
if (form.tagName !== 'FORM') return;
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.classList.add('loading');
// Add loading text
const originalText = submitButton.value || submitButton.textContent;
submitButton.dataset.originalText = originalText;
submitButton.value = submitButton.value ? 'Submitting...' : submitButton.value;
submitButton.textContent = submitButton.textContent ? 'Submitting...' : submitButton.textContent;
}
// Reset on error
form.addEventListener('error', () => {
if (submitButton) {
submitButton.disabled = false;
submitButton.classList.remove('loading');
submitButton.value = submitButton.dataset.originalText || submitButton.value;
submitButton.textContent = submitButton.dataset.originalText || submitButton.textContent;
}
}, { once: true });
});
// Add retry functionality for failed requests
window.retryRequest = async (requestFn, maxRetries = 3, delay = 1000) => {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await requestFn();
} catch (error) {
lastError = error;
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
}
}
}
throw lastError;
};
}
}
// Auto-initialize
document.addEventListener('DOMContentLoaded', () => {
new UIEnhancements();
});