manage-your-printer/static/js/notifications.js
2025-06-04 10:03:22 +02:00

667 lines
25 KiB
JavaScript

/**
* Modernes Benachrichtigungssystem für die MYP 3D-Druck Platform
* Verwaltet einheitliche Glassmorphism-Benachrichtigungen aller Art
*/
class ModernNotificationManager {
constructor() {
this.notificationToggle = document.getElementById('notificationToggle');
this.notificationDropdown = document.getElementById('notificationDropdown');
this.notificationBadge = document.getElementById('notificationBadge');
this.notificationList = document.getElementById('notificationList');
this.markAllReadBtn = document.getElementById('markAllRead');
this.isOpen = false;
this.notifications = [];
this.activeToasts = new Map();
this.toastCounter = 0;
// CSRF-Token aus Meta-Tag holen
this.csrfToken = this.getCSRFToken();
this.init();
this.setupGlobalNotificationSystem();
}
/**
* Holt das CSRF-Token aus dem Meta-Tag
* @returns {string} Das CSRF-Token
*/
getCSRFToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
return metaTag ? metaTag.getAttribute('content') : '';
}
/**
* Erstellt die Standard-Headers für API-Anfragen mit CSRF-Token
* @returns {Object} Headers-Objekt
*/
getAPIHeaders() {
const headers = {
'Content-Type': 'application/json',
};
if (this.csrfToken) {
headers['X-CSRFToken'] = this.csrfToken;
}
return headers;
}
init() {
if (!this.notificationToggle) return;
// Event Listeners
this.notificationToggle.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleDropdown();
});
if (this.markAllReadBtn) {
this.markAllReadBtn.addEventListener('click', () => {
this.markAllAsRead();
});
}
// Dropdown schließen bei Klick außerhalb
document.addEventListener('click', (e) => {
if (!this.notificationDropdown.contains(e.target) && !this.notificationToggle.contains(e.target)) {
this.closeDropdown();
}
});
// Benachrichtigungen laden
this.loadNotifications();
// Regelmäßige Updates
setInterval(() => {
this.loadNotifications();
}, 30000); // Alle 30 Sekunden
}
toggleDropdown() {
if (this.isOpen) {
this.closeDropdown();
} else {
this.openDropdown();
}
}
openDropdown() {
this.notificationDropdown.classList.remove('hidden');
this.notificationToggle.setAttribute('aria-expanded', 'true');
this.isOpen = true;
// Animation
this.notificationDropdown.style.opacity = '0';
this.notificationDropdown.style.transform = 'translateY(-10px)';
requestAnimationFrame(() => {
this.notificationDropdown.style.transition = 'opacity 0.2s ease, transform 0.2s ease';
this.notificationDropdown.style.opacity = '1';
this.notificationDropdown.style.transform = 'translateY(0)';
});
}
closeDropdown() {
this.notificationDropdown.style.transition = 'opacity 0.2s ease, transform 0.2s ease';
this.notificationDropdown.style.opacity = '0';
this.notificationDropdown.style.transform = 'translateY(-10px)';
setTimeout(() => {
this.notificationDropdown.classList.add('hidden');
this.notificationToggle.setAttribute('aria-expanded', 'false');
this.isOpen = false;
}, 200);
}
async loadNotifications() {
try {
const response = await fetch('/api/notifications');
if (response.ok) {
const data = await response.json();
this.notifications = data.notifications || [];
this.updateUI();
}
} catch (error) {
console.error('Fehler beim Laden der Benachrichtigungen:', error);
}
}
updateUI() {
this.updateBadge();
this.updateNotificationList();
}
updateBadge() {
const unreadCount = this.notifications.filter(n => !n.read).length;
if (unreadCount > 0) {
this.notificationBadge.textContent = unreadCount > 99 ? '99+' : unreadCount.toString();
this.notificationBadge.classList.remove('hidden');
} else {
this.notificationBadge.classList.add('hidden');
}
}
updateNotificationList() {
if (this.notifications.length === 0) {
this.notificationList.innerHTML = `
<div class="p-4 text-center text-slate-500 dark:text-slate-400">
Keine neuen Benachrichtigungen
</div>
`;
return;
}
const notificationHTML = this.notifications.map(notification => {
const isUnread = !notification.read;
const timeAgo = this.formatTimeAgo(new Date(notification.created_at));
return `
<div class="notification-item p-4 border-b border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors ${isUnread ? 'bg-blue-50 dark:bg-blue-900/20' : ''}"
data-notification-id="${notification.id}">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
${this.getNotificationIcon(notification.type)}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-slate-900 dark:text-white">
${this.getNotificationTitle(notification.type)}
</p>
${isUnread ? '<div class="w-2 h-2 bg-blue-500 rounded-full"></div>' : ''}
</div>
<p class="text-sm text-slate-600 dark:text-slate-400 mt-1">
${this.getNotificationMessage(notification)}
</p>
<p class="text-xs text-slate-500 dark:text-slate-500 mt-2">
${timeAgo}
</p>
</div>
</div>
</div>
`;
}).join('');
this.notificationList.innerHTML = notificationHTML;
// Event Listeners für Benachrichtigungen
this.notificationList.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', (e) => {
const notificationId = item.dataset.notificationId;
this.markAsRead(notificationId);
});
});
}
getNotificationIcon(type) {
const icons = {
'guest_request': `
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
`,
'job_completed': `
<div class="w-8 h-8 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
`,
'system': `
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-900 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
`
};
return icons[type] || icons['system'];
}
getNotificationTitle(type) {
const titles = {
'guest_request': 'Neue Gastanfrage',
'job_completed': 'Druckauftrag abgeschlossen',
'system': 'System-Benachrichtigung'
};
return titles[type] || 'Benachrichtigung';
}
getNotificationMessage(notification) {
try {
const payload = JSON.parse(notification.payload || '{}');
switch (notification.type) {
case 'guest_request':
return `${payload.guest_name || 'Ein Gast'} hat eine neue Druckanfrage gestellt.`;
case 'job_completed':
return `Der Druckauftrag "${payload.job_name || 'Unbekannt'}" wurde erfolgreich abgeschlossen.`;
default:
return payload.message || 'Neue Benachrichtigung erhalten.';
}
} catch (error) {
return 'Neue Benachrichtigung erhalten.';
}
}
formatTimeAgo(date) {
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return 'Gerade eben';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `vor ${minutes} Minute${minutes !== 1 ? 'n' : ''}`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `vor ${hours} Stunde${hours !== 1 ? 'n' : ''}`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `vor ${days} Tag${days !== 1 ? 'en' : ''}`;
}
}
async markAsRead(notificationId) {
try {
const response = await fetch(`/api/notifications/${notificationId}/read`, {
method: 'POST',
headers: this.getAPIHeaders()
});
if (response.ok) {
// Benachrichtigung als gelesen markieren
const notification = this.notifications.find(n => n.id == notificationId);
if (notification) {
notification.read = true;
this.updateUI();
}
} else {
console.error('Fehler beim Markieren als gelesen:', response.status, response.statusText);
}
} catch (error) {
console.error('Fehler beim Markieren als gelesen:', error);
}
}
async markAllAsRead() {
try {
const response = await fetch('/api/notifications/mark-all-read', {
method: 'POST',
headers: this.getAPIHeaders()
});
if (response.ok) {
// Alle Benachrichtigungen als gelesen markieren
this.notifications.forEach(notification => {
notification.read = true;
});
this.updateUI();
} else {
console.error('Fehler beim Markieren aller als gelesen:', response.status, response.statusText);
}
} catch (error) {
console.error('Fehler beim Markieren aller als gelesen:', error);
}
}
setupGlobalNotificationSystem() {
// Globale Funktionen für einheitliche Benachrichtigungen
window.showFlashMessage = this.showGlassToast.bind(this);
window.showToast = this.showGlassToast.bind(this);
window.showNotification = this.showGlassToast.bind(this);
window.showSuccessMessage = (message) => this.showGlassToast(message, 'success');
window.showErrorMessage = (message) => this.showGlassToast(message, 'error');
window.showWarningMessage = (message) => this.showGlassToast(message, 'warning');
window.showInfoMessage = (message) => this.showGlassToast(message, 'info');
}
/**
* Zeigt eine moderne Glassmorphism-Toast-Benachrichtigung
*/
showGlassToast(message, type = 'info', duration = 5000, options = {}) {
// DND-Check
if (window.dndManager && window.dndManager.isEnabled) {
window.dndManager.suppressNotification(message, type);
return;
}
const toastId = `toast-${++this.toastCounter}`;
// Toast-Element erstellen
const toast = document.createElement('div');
toast.id = toastId;
toast.className = `glass-toast notification notification-${type} show`;
// Icon basierend auf Typ
const iconMap = {
success: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>`,
error: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>`,
warning: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>`,
info: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`
};
toast.innerHTML = `
<div class="flex items-center">
<div class="notification-icon">
${iconMap[type] || iconMap.info}
</div>
<div class="notification-content">
${options.title ? `<div class="notification-title">${options.title}</div>` : ''}
<div class="notification-message">${message}</div>
</div>
<button class="notification-close" onclick="modernNotificationManager.closeToast('${toastId}')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
`;
// Container für Toasts erstellen falls nicht vorhanden
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'notifications-container';
document.body.appendChild(container);
}
// Position berechnen basierend auf existierenden Toasts
const existingToasts = container.children.length;
toast.style.top = `${1 + existingToasts * 5}rem`;
container.appendChild(toast);
this.activeToasts.set(toastId, toast);
// Hover-Effekt pausiert Auto-Close
let isPaused = false;
let timeoutId;
const startTimer = () => {
if (!isPaused && duration > 0) {
timeoutId = setTimeout(() => {
this.closeToast(toastId);
}, duration);
}
};
toast.addEventListener('mouseenter', () => {
isPaused = true;
clearTimeout(timeoutId);
toast.style.transform = 'translateY(-2px) scale(1.02)';
});
toast.addEventListener('mouseleave', () => {
isPaused = false;
toast.style.transform = 'translateY(0) scale(1)';
startTimer();
});
// Initial timer starten
setTimeout(startTimer, 100);
// Sound-Effekt (optional)
if (options.playSound !== false) {
this.playNotificationSound(type);
}
return toastId;
}
/**
* Schließt eine spezifische Toast-Benachrichtigung
*/
closeToast(toastId) {
const toast = this.activeToasts.get(toastId);
if (!toast) return;
toast.classList.add('hiding');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
this.activeToasts.delete(toastId);
this.repositionToasts();
}, 400);
}
/**
* Repositioniert alle aktiven Toasts
*/
repositionToasts() {
let index = 0;
this.activeToasts.forEach((toast) => {
if (toast.parentNode) {
toast.style.top = `${1 + index * 5}rem`;
index++;
}
});
}
/**
* Spielt einen Benachrichtigungston ab
*/
playNotificationSound(type) {
try {
// Nur wenn AudioContext verfügbar ist
if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Verschiedene Töne für verschiedene Typen
const frequencies = {
success: [523.25, 659.25, 783.99], // C-E-G Akkord
error: [440, 415.3], // A-Ab
warning: [493.88, 523.25], // B-C
info: [523.25] // C
};
const freq = frequencies[type] || frequencies.info;
freq.forEach((f, i) => {
setTimeout(() => {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(f, audioContext.currentTime);
gain.gain.setValueAtTime(0.1, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.1);
}, i * 100);
});
}
} catch (error) {
// Silent fail - Audio nicht verfügbar
}
}
/**
* Zeigt Browser-Benachrichtigung (falls berechtigt)
*/
showBrowserNotification(title, message, options = {}) {
if ('Notification' in window) {
if (Notification.permission === 'granted') {
const notification = new Notification(title, {
body: message,
icon: options.icon || '/static/icons/static/icons/notification-icon.png',
badge: options.badge || '/static/icons/static/icons/badge-icon.png',
tag: options.tag || 'myp-notification',
requireInteraction: options.requireInteraction || false,
...options
});
notification.onclick = options.onClick || (() => {
window.focus();
notification.close();
});
return notification;
} else if (Notification.permission === 'default') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
this.showBrowserNotification(title, message, options);
}
});
}
}
return null;
}
/**
* Zeigt eine Alert-Benachrichtigung mit Glassmorphism
*/
showAlert(message, type = 'info', options = {}) {
const alertId = `alert-${Date.now()}`;
const alert = document.createElement('div');
alert.id = alertId;
alert.className = `alert alert-${type}`;
alert.innerHTML = `
<div class="flex items-start">
<div class="notification-icon mr-3">
${this.getIconForType(type)}
</div>
<div class="flex-1">
${options.title ? `<h4 class="font-semibold mb-2">${options.title}</h4>` : ''}
<p>${message}</p>
</div>
${options.dismissible !== false ? `
<button class="ml-3 p-1 rounded-lg opacity-70 hover:opacity-100 transition-opacity" onclick="document.getElementById('${alertId}').remove()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
` : ''}
</div>
`;
// Alert in Container einfügen
const container = options.container || document.querySelector('.flash-messages') || document.body;
container.appendChild(alert);
// Auto-dismiss
if (options.autoDismiss !== false) {
setTimeout(() => {
if (alert.parentNode) {
alert.style.opacity = '0';
alert.style.transform = 'translateY(-20px)';
setTimeout(() => alert.remove(), 300);
}
}, options.duration || 7000);
}
return alertId;
}
getIconForType(type) {
const icons = {
success: `<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>`,
error: `<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>`,
warning: `<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>`,
info: `<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`
};
return icons[type] || icons.info;
}
}
// Legacy NotificationManager für Rückwärtskompatibilität
class NotificationManager extends ModernNotificationManager {
constructor() {
super();
console.warn('NotificationManager ist deprecated. Verwenden Sie ModernNotificationManager.');
}
}
// Globale Instanz erstellen
const modernNotificationManager = new ModernNotificationManager();
// Für Rückwärtskompatibilität
if (typeof window !== 'undefined') {
window.notificationManager = modernNotificationManager;
window.modernNotificationManager = modernNotificationManager;
}
// CSS für glassmorphe Toasts hinzufügen
const toastStyles = `
<style>
.glass-toast {
position: relative;
transform: translateX(0) translateY(0) scale(1);
animation: toast-slide-in 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-toast:hover {
transform: translateY(-2px) scale(1.02) !important;
transition: transform 0.2s ease;
}
@keyframes toast-slide-in {
0% {
opacity: 0;
transform: translateX(100%) translateY(-20px) scale(0.9);
}
50% {
opacity: 0.8;
transform: translateX(20px) translateY(-10px) scale(1.05);
}
100% {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
}
}
.notification-item.unread {
background: linear-gradient(135deg,
rgba(59, 130, 246, 0.08) 0%,
rgba(147, 197, 253, 0.05) 100%);
border-left: 3px solid rgb(59, 130, 246);
}
.dark .notification-item.unread {
background: linear-gradient(135deg,
rgba(59, 130, 246, 0.15) 0%,
rgba(147, 197, 253, 0.08) 100%);
}
</style>
`;
// Styles zur Seite hinzufügen
if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) {
const styleElement = document.createElement('div');
styleElement.id = 'toast-styles';
styleElement.innerHTML = toastStyles;
document.head.appendChild(styleElement);
}