275 lines
7.4 KiB
JavaScript
275 lines
7.4 KiB
JavaScript
import { t } from '../i18n.js'
|
||
import { escapeHTML } from '../utils/helpers.js'
|
||
|
||
/**
|
||
* Error Boundary Component
|
||
* Catches errors in child components and displays fallback UI
|
||
*/
|
||
class ErrorBoundary extends HTMLElement {
|
||
constructor() {
|
||
super()
|
||
this.hasError = false
|
||
this.error = null
|
||
}
|
||
|
||
connectedCallback() {
|
||
this.setupErrorHandling()
|
||
}
|
||
|
||
setupErrorHandling() {
|
||
// Catch errors from child components
|
||
this.addEventListener('error', (e) => {
|
||
e.stopPropagation()
|
||
this.handleError(e.error || e.message || 'Unknown error')
|
||
})
|
||
}
|
||
|
||
handleError(error) {
|
||
console.error('ErrorBoundary caught:', error)
|
||
this.hasError = true
|
||
this.error = error
|
||
this.renderError()
|
||
}
|
||
|
||
renderError() {
|
||
const errorMessage = this.error?.message || String(this.error) || t('common.error')
|
||
|
||
this.innerHTML = /* html */`
|
||
<div class="error-boundary">
|
||
<div class="error-icon">⚠️</div>
|
||
<h3 class="error-title">${t('error.title') || 'Etwas ist schiefgelaufen'}</h3>
|
||
<p class="error-message">${escapeHTML(errorMessage)}</p>
|
||
<button class="btn btn-primary error-retry" type="button">
|
||
${t('error.retry') || 'Erneut versuchen'}
|
||
</button>
|
||
</div>
|
||
`
|
||
|
||
this.querySelector('.error-retry')?.addEventListener('click', () => this.retry())
|
||
}
|
||
|
||
retry() {
|
||
this.hasError = false
|
||
this.error = null
|
||
this.innerHTML = ''
|
||
this.dispatchEvent(new CustomEvent('retry', { bubbles: true }))
|
||
}
|
||
|
||
reset() {
|
||
this.hasError = false
|
||
this.error = null
|
||
}
|
||
|
||
}
|
||
|
||
customElements.define('error-boundary', ErrorBoundary)
|
||
|
||
/**
|
||
* Global error handler for uncaught errors
|
||
*/
|
||
export function setupGlobalErrorHandler() {
|
||
window.addEventListener('error', (event) => {
|
||
console.error('Global error:', event.error)
|
||
|
||
// Don't show UI for script loading errors
|
||
if (event.filename && event.filename.includes('.js')) {
|
||
showErrorToast(event.message || 'Ein Fehler ist aufgetreten')
|
||
}
|
||
})
|
||
|
||
window.addEventListener('unhandledrejection', (event) => {
|
||
console.error('Unhandled promise rejection:', event.reason)
|
||
showErrorToast(event.reason?.message || 'Ein Fehler ist aufgetreten')
|
||
})
|
||
|
||
// Offline/Online detection
|
||
setupOfflineIndicator()
|
||
}
|
||
|
||
/**
|
||
* Shows/hides offline indicator based on network status
|
||
*/
|
||
function setupOfflineIndicator() {
|
||
const updateStatus = () => {
|
||
let indicator = document.querySelector('.offline-indicator')
|
||
|
||
if (!navigator.onLine) {
|
||
if (!indicator) {
|
||
indicator = document.createElement('div')
|
||
indicator.className = 'offline-indicator'
|
||
indicator.innerHTML = `
|
||
<span class="offline-icon">📡</span>
|
||
<span class="offline-text">Offline</span>
|
||
`
|
||
document.body.appendChild(indicator)
|
||
requestAnimationFrame(() => indicator.classList.add('visible'))
|
||
}
|
||
} else {
|
||
if (indicator) {
|
||
indicator.classList.remove('visible')
|
||
setTimeout(() => indicator.remove(), 300)
|
||
}
|
||
}
|
||
}
|
||
|
||
window.addEventListener('online', updateStatus)
|
||
window.addEventListener('offline', updateStatus)
|
||
|
||
// Check on init
|
||
updateStatus()
|
||
}
|
||
|
||
/**
|
||
* Shows a temporary error toast notification
|
||
*/
|
||
function showErrorToast(message) {
|
||
// Remove existing toast
|
||
document.querySelector('.error-toast')?.remove()
|
||
|
||
const toast = document.createElement('div')
|
||
toast.className = 'error-toast'
|
||
toast.innerHTML = /* html */`
|
||
<span class="error-toast-icon">⚠️</span>
|
||
<span class="error-toast-message">${escapeHTML(message)}</span>
|
||
<button class="error-toast-close" aria-label="Schließen">×</button>
|
||
`
|
||
|
||
document.body.appendChild(toast)
|
||
|
||
// Auto-dismiss after 5 seconds
|
||
const timeout = setTimeout(() => toast.remove(), 5000)
|
||
|
||
toast.querySelector('.error-toast-close')?.addEventListener('click', () => {
|
||
clearTimeout(timeout)
|
||
toast.remove()
|
||
})
|
||
|
||
// Trigger animation
|
||
requestAnimationFrame(() => toast.classList.add('visible'))
|
||
}
|
||
|
||
// Styles
|
||
const style = document.createElement('style')
|
||
style.textContent = /* css */`
|
||
error-boundary {
|
||
display: block;
|
||
}
|
||
|
||
error-boundary .error-boundary {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: var(--space-2xl);
|
||
text-align: center;
|
||
min-height: 200px;
|
||
}
|
||
|
||
error-boundary .error-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: var(--space-md);
|
||
filter: grayscale(1);
|
||
}
|
||
|
||
error-boundary .error-title {
|
||
font-size: var(--font-size-lg);
|
||
margin: 0 0 var(--space-sm);
|
||
color: var(--color-text);
|
||
}
|
||
|
||
error-boundary .error-message {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
margin: 0 0 var(--space-lg);
|
||
max-width: 400px;
|
||
}
|
||
|
||
/* Error Toast */
|
||
.error-toast {
|
||
position: fixed;
|
||
bottom: var(--space-lg);
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(100px);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
padding: var(--space-sm) var(--space-md);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-lg);
|
||
z-index: 10000;
|
||
opacity: 0;
|
||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||
}
|
||
|
||
.error-toast.visible {
|
||
transform: translateX(-50%) translateY(0);
|
||
opacity: 1;
|
||
}
|
||
|
||
.error-toast-icon {
|
||
filter: grayscale(1);
|
||
}
|
||
|
||
.error-toast-message {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text);
|
||
max-width: 300px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.error-toast-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: var(--font-size-lg);
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
.error-toast-close:hover {
|
||
color: var(--color-text);
|
||
}
|
||
|
||
/* Offline Indicator */
|
||
.offline-indicator {
|
||
position: fixed;
|
||
top: var(--space-md);
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(-100px);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-xs);
|
||
padding: var(--space-xs) var(--space-md);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-full);
|
||
box-shadow: var(--shadow-md);
|
||
z-index: 10001;
|
||
opacity: 0;
|
||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||
}
|
||
|
||
.offline-indicator.visible {
|
||
transform: translateX(-50%) translateY(0);
|
||
opacity: 1;
|
||
}
|
||
|
||
.offline-indicator .offline-icon {
|
||
filter: grayscale(1);
|
||
}
|
||
|
||
.offline-indicator .offline-text {
|
||
font-size: var(--font-size-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text);
|
||
}
|
||
`
|
||
document.head.appendChild(style)
|
||
|
||
export { ErrorBoundary }
|