/** * Countdown-Timer mit Force-Quit-Funktionalität * * Dieses Modul stellt eine vollständige Timer-UI bereit mit: * - Visueller Countdown-Anzeige * - Fortschrittsbalken * - Warnungen vor Ablauf * - Force-Quit-Aktionen * - Responsive Design * * Autor: System * Erstellt: 2025 */ class CountdownTimer { /** * Erstellt einen neuen Countdown-Timer * @param {Object} options - Konfigurationsoptionen */ constructor(options = {}) { // Standard-Konfiguration this.config = { // Timer-Grundeinstellungen name: options.name || 'default_timer', duration: options.duration || 1800, // 30 Minuten in Sekunden autoStart: options.autoStart || false, // UI-Einstellungen container: options.container || 'countdown-timer', size: options.size || 'large', // small, medium, large theme: options.theme || 'primary', // primary, warning, danger, success showProgress: options.showProgress !== false, showControls: options.showControls !== false, // Warnungs-Einstellungen warningThreshold: options.warningThreshold || 30, // Warnung 30 Sekunden vor Ablauf showWarning: options.showWarning !== false, warningMessage: options.warningMessage || 'Timer läuft ab!', // Force-Quit-Einstellungen forceQuitEnabled: options.forceQuitEnabled !== false, forceQuitAction: options.forceQuitAction || 'logout', customEndpoint: options.customEndpoint || null, // Callback-Funktionen onTick: options.onTick || null, onWarning: options.onWarning || null, onExpired: options.onExpired || null, onForceQuit: options.onForceQuit || null, // API-Einstellungen apiBase: options.apiBase || '/api/timers', updateInterval: options.updateInterval || 1000, syncWithServer: options.syncWithServer !== false }; // Timer-Zustand this.state = { remaining: this.config.duration, total: this.config.duration, status: 'stopped', // stopped, running, paused, expired warningShown: false, lastServerSync: null }; // DOM-Elemente this.elements = {}; // Timer-Intervalle this.intervals = { countdown: null, serverSync: null }; // Event-Listener this.listeners = new Map(); this.init(); } /** * Initialisiert den Timer */ init() { this.createUI(); this.attachEventListeners(); if (this.config.syncWithServer) { this.syncWithServer(); this.startServerSync(); } if (this.config.autoStart) { this.start(); } console.log(`Timer '${this.config.name}' initialisiert`); } /** * Erstellt die Timer-UI */ createUI() { const container = document.getElementById(this.config.container); if (!container) { console.error(`Container '${this.config.container}' nicht gefunden`); return; } // Haupt-Container erstellen const timerWrapper = document.createElement('div'); timerWrapper.className = `countdown-timer-wrapper size-${this.config.size} theme-${this.config.theme}`; timerWrapper.innerHTML = this.getTimerHTML(); container.appendChild(timerWrapper); // DOM-Referenzen speichern this.elements = { wrapper: timerWrapper, display: timerWrapper.querySelector('.timer-display'), timeText: timerWrapper.querySelector('.time-text'), progressBar: timerWrapper.querySelector('.progress-fill'), progressText: timerWrapper.querySelector('.progress-text'), statusIndicator: timerWrapper.querySelector('.status-indicator'), warningBox: timerWrapper.querySelector('.warning-box'), warningText: timerWrapper.querySelector('.warning-text'), controls: timerWrapper.querySelector('.timer-controls'), startBtn: timerWrapper.querySelector('.btn-start'), pauseBtn: timerWrapper.querySelector('.btn-pause'), stopBtn: timerWrapper.querySelector('.btn-stop'), resetBtn: timerWrapper.querySelector('.btn-reset'), extendBtn: timerWrapper.querySelector('.btn-extend') }; this.updateDisplay(); } /** * Generiert das HTML für den Timer */ getTimerHTML() { return `
${this.formatTime(this.state.remaining)} verbleibend
Gestoppt
${this.config.showProgress ? `
0% abgelaufen
` : ''} ${this.config.showControls ? `
` : ''}
`; } /** * Registriert Event-Listener */ attachEventListeners() { if (this.elements.startBtn) { this.elements.startBtn.addEventListener('click', () => this.start()); } if (this.elements.pauseBtn) { this.elements.pauseBtn.addEventListener('click', () => this.pause()); } if (this.elements.stopBtn) { this.elements.stopBtn.addEventListener('click', () => this.stop()); } if (this.elements.resetBtn) { this.elements.resetBtn.addEventListener('click', () => this.reset()); } if (this.elements.extendBtn) { this.elements.extendBtn.addEventListener('click', () => this.extend(300)); // 5 Minuten } // Globale Events für Tastatur-Shortcuts document.addEventListener('keydown', (e) => this.handleKeyboardShortcuts(e)); // Page Visibility API für Pause bei Tab-Wechsel document.addEventListener('visibilitychange', () => this.handleVisibilityChange()); // Before Unload Event für Warnung window.addEventListener('beforeunload', (e) => this.handleBeforeUnload(e)); } /** * Startet den Timer */ async start() { try { if (this.state.status === 'running') { return true; } // Server-API aufrufen wenn aktiviert if (this.config.syncWithServer) { const response = await this.apiCall('start', 'POST'); if (!response.success) { this.showError('Fehler beim Starten des Timers'); return false; } } this.state.status = 'running'; this.startCountdown(); this.updateControls(); this.updateStatusIndicator(); console.log(`Timer '${this.config.name}' gestartet`); return true; } catch (error) { console.error('Fehler beim Starten des Timers:', error); this.showError('Timer konnte nicht gestartet werden'); return false; } } /** * Pausiert den Timer */ async pause() { try { if (this.state.status !== 'running') { return true; } // Server-API aufrufen wenn aktiviert if (this.config.syncWithServer) { const response = await this.apiCall('pause', 'POST'); if (!response.success) { this.showError('Fehler beim Pausieren des Timers'); return false; } } this.state.status = 'paused'; this.stopCountdown(); this.updateControls(); this.updateStatusIndicator(); console.log(`Timer '${this.config.name}' pausiert`); return true; } catch (error) { console.error('Fehler beim Pausieren des Timers:', error); this.showError('Timer konnte nicht pausiert werden'); return false; } } /** * Stoppt den Timer */ async stop() { try { // Server-API aufrufen wenn aktiviert if (this.config.syncWithServer) { const response = await this.apiCall('stop', 'POST'); if (!response.success) { this.showError('Fehler beim Stoppen des Timers'); return false; } } this.state.status = 'stopped'; this.state.remaining = this.state.total; this.state.warningShown = false; this.stopCountdown(); this.hideWarning(); this.updateDisplay(); this.updateControls(); this.updateStatusIndicator(); console.log(`Timer '${this.config.name}' gestoppt`); return true; } catch (error) { console.error('Fehler beim Stoppen des Timers:', error); this.showError('Timer konnte nicht gestoppt werden'); return false; } } /** * Setzt den Timer zurück */ async reset() { try { // Server-API aufrufen wenn aktiviert if (this.config.syncWithServer) { const response = await this.apiCall('reset', 'POST'); if (!response.success) { this.showError('Fehler beim Zurücksetzen des Timers'); return false; } } this.stop(); this.state.remaining = this.state.total; this.updateDisplay(); console.log(`Timer '${this.config.name}' zurückgesetzt`); return true; } catch (error) { console.error('Fehler beim Zurücksetzen des Timers:', error); this.showError('Timer konnte nicht zurückgesetzt werden'); return false; } } /** * Verlängert den Timer */ async extend(seconds) { try { // Server-API aufrufen wenn aktiviert if (this.config.syncWithServer) { const response = await this.apiCall('extend', 'POST', { seconds }); if (!response.success) { this.showError('Fehler beim Verlängern des Timers'); return false; } } this.state.remaining += seconds; this.state.total += seconds; this.state.warningShown = false; this.hideWarning(); this.updateDisplay(); // Toast-Benachrichtigung this.showToast(`Timer um ${Math.floor(seconds / 60)} Minuten verlängert`, 'success'); console.log(`Timer '${this.config.name}' um ${seconds} Sekunden verlängert`); return true; } catch (error) { console.error('Fehler beim Verlängern des Timers:', error); this.showError('Timer konnte nicht verlängert werden'); return false; } } /** * Startet den Countdown-Mechanismus */ startCountdown() { this.stopCountdown(); // Sicherstellen, dass kein anderer Countdown läuft this.intervals.countdown = setInterval(() => { this.tick(); }, this.config.updateInterval); } /** * Stoppt den Countdown-Mechanismus */ stopCountdown() { if (this.intervals.countdown) { clearInterval(this.intervals.countdown); this.intervals.countdown = null; } } /** * Timer-Tick (wird jede Sekunde aufgerufen) */ tick() { if (this.state.status !== 'running') { return; } this.state.remaining = Math.max(0, this.state.remaining - 1); this.updateDisplay(); // Callback aufrufen if (this.config.onTick) { this.config.onTick(this.state.remaining, this.state.total); } // Warnung prüfen if (!this.state.warningShown && this.state.remaining <= this.config.warningThreshold && this.state.remaining > 0) { this.showWarning(); } // Timer abgelaufen prüfen if (this.state.remaining <= 0) { this.handleExpired(); } } /** * Behandelt Timer-Ablauf */ async handleExpired() { console.warn(`Timer '${this.config.name}' ist abgelaufen`); this.state.status = 'expired'; this.stopCountdown(); this.updateDisplay(); this.updateStatusIndicator(); // Callback aufrufen if (this.config.onExpired) { this.config.onExpired(); } // Force-Quit ausführen wenn aktiviert if (this.config.forceQuitEnabled) { await this.executeForceQuit(); } } /** * Führt Force-Quit-Aktion aus */ async executeForceQuit() { try { console.warn(`Force-Quit für Timer '${this.config.name}' wird ausgeführt...`); // Callback aufrufen if (this.config.onForceQuit) { const shouldContinue = this.config.onForceQuit(this.config.forceQuitAction); if (!shouldContinue) { return; } } // Server-API aufrufen if (this.config.syncWithServer) { const response = await this.apiCall('force-quit', 'POST'); if (!response.success) { console.error('Force-Quit-API-Aufruf fehlgeschlagen'); } } // Lokale Force-Quit-Aktionen switch (this.config.forceQuitAction) { case 'logout': this.performLogout(); break; case 'redirect': this.performRedirect(); break; case 'refresh': this.performRefresh(); break; case 'custom': this.performCustomAction(); break; default: console.warn(`Unbekannte Force-Quit-Aktion: ${this.config.forceQuitAction}`); } } catch (error) { console.error('Fehler bei Force-Quit-Ausführung:', error); } } /** * Führt Logout-Aktion aus */ performLogout() { this.showModal('Session abgelaufen', 'Sie werden automatisch abgemeldet...', 'warning'); setTimeout(() => { window.location.href = '/auth/logout'; }, 2000); } /** * Führt Redirect-Aktion aus */ performRedirect() { const redirectUrl = this.config.redirectUrl || '/'; this.showModal('Umleitung', 'Sie werden weitergeleitet...', 'info'); setTimeout(() => { window.location.href = redirectUrl; }, 2000); } /** * Führt Refresh-Aktion aus */ performRefresh() { this.showModal('Seite wird aktualisiert', 'Die Seite wird automatisch neu geladen...', 'info'); setTimeout(() => { window.location.reload(); }, 2000); } /** * Führt benutzerdefinierte Aktion aus */ performCustomAction() { if (this.config.customEndpoint) { fetch(this.config.customEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCSRFToken() }, body: JSON.stringify({ timer_name: this.config.name, action: 'force_quit' }) }).catch(error => { console.error('Custom-Action-Request fehlgeschlagen:', error); }); } } /** * Zeigt Warnung an */ showWarning() { if (!this.config.showWarning || this.state.warningShown) { return; } this.state.warningShown = true; if (this.elements.warningBox) { this.elements.warningBox.style.display = 'block'; this.elements.warningBox.classList.add('pulse'); } // Callback aufrufen if (this.config.onWarning) { this.config.onWarning(this.state.remaining); } // Browser-Benachrichtigung this.showNotification('Timer-Warnung', this.config.warningMessage); console.warn(`Timer-Warnung für '${this.config.name}': ${this.state.remaining} Sekunden verbleiben`); } /** * Versteckt Warnung */ hideWarning() { this.state.warningShown = false; if (this.elements.warningBox) { this.elements.warningBox.style.display = 'none'; this.elements.warningBox.classList.remove('pulse'); } } /** * Aktualisiert die Anzeige */ updateDisplay() { // Zeit-Text aktualisieren if (this.elements.timeText) { this.elements.timeText.textContent = this.formatTime(this.state.remaining); } // Fortschrittsbalken aktualisieren if (this.config.showProgress && this.elements.progressBar) { const progress = ((this.state.total - this.state.remaining) / this.state.total) * 100; this.elements.progressBar.style.width = `${progress}%`; if (this.elements.progressText) { this.elements.progressText.textContent = `${Math.round(progress)}% abgelaufen`; } } // Theme basierend auf verbleibender Zeit anpassen this.updateTheme(); } /** * Aktualisiert das Farbthema basierend auf verbleibender Zeit */ updateTheme() { if (!this.elements.wrapper) return; const progress = (this.state.total - this.state.remaining) / this.state.total; // Theme-Klassen entfernen this.elements.wrapper.classList.remove('theme-primary', 'theme-warning', 'theme-danger'); // Neues Theme basierend auf Fortschritt if (progress < 0.7) { this.elements.wrapper.classList.add('theme-primary'); } else if (progress < 0.9) { this.elements.wrapper.classList.add('theme-warning'); } else { this.elements.wrapper.classList.add('theme-danger'); } } /** * Aktualisiert die Steuerungsbuttons */ updateControls() { if (!this.config.showControls) return; const isRunning = this.state.status === 'running'; const isPaused = this.state.status === 'paused'; const isStopped = this.state.status === 'stopped'; if (this.elements.startBtn) { this.elements.startBtn.style.display = (isStopped || isPaused) ? 'inline-flex' : 'none'; } if (this.elements.pauseBtn) { this.elements.pauseBtn.style.display = isRunning ? 'inline-flex' : 'none'; } if (this.elements.stopBtn) { this.elements.stopBtn.disabled = isStopped; } if (this.elements.resetBtn) { this.elements.resetBtn.disabled = isRunning; } if (this.elements.extendBtn) { this.elements.extendBtn.disabled = this.state.status === 'expired'; } } /** * Aktualisiert den Status-Indikator */ updateStatusIndicator() { if (!this.elements.statusIndicator) return; const statusText = this.elements.statusIndicator.querySelector('.status-text'); const statusIcon = this.elements.statusIndicator.querySelector('i'); if (statusText && statusIcon) { switch (this.state.status) { case 'running': statusText.textContent = 'Läuft'; statusIcon.className = 'fas fa-circle text-success'; break; case 'paused': statusText.textContent = 'Pausiert'; statusIcon.className = 'fas fa-circle text-warning'; break; case 'expired': statusText.textContent = 'Abgelaufen'; statusIcon.className = 'fas fa-circle text-danger'; break; default: statusText.textContent = 'Gestoppt'; statusIcon.className = 'fas fa-circle text-secondary'; } } } /** * Formatiert Zeit in MM:SS Format */ formatTime(seconds) { const minutes = Math.floor(seconds / 60); const secs = seconds % 60; return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } /** * Synchronisiert mit Server */ async syncWithServer() { try { const response = await this.apiCall('status', 'GET'); if (response.success && response.data) { const serverState = response.data; // Lokalen Zustand mit Server-Zustand synchronisieren this.state.remaining = serverState.remaining_seconds || this.state.remaining; this.state.total = serverState.duration_seconds || this.state.total; this.state.status = serverState.status || this.state.status; this.updateDisplay(); this.updateControls(); this.updateStatusIndicator(); this.state.lastServerSync = new Date(); } } catch (error) { console.error('Server-Synchronisation fehlgeschlagen:', error); } } /** * Startet regelmäßige Server-Synchronisation */ startServerSync() { if (this.intervals.serverSync) { clearInterval(this.intervals.serverSync); } this.intervals.serverSync = setInterval(() => { this.syncWithServer(); }, 30000); // Alle 30 Sekunden synchronisieren } /** * API-Aufruf an Server */ async apiCall(action, method = 'GET', data = null) { const url = `${this.config.apiBase}/${this.config.name}/${action}`; const options = { method, headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCSRFToken() } }; if (data && (method === 'POST' || method === 'PUT')) { options.body = JSON.stringify(data); } const response = await fetch(url, options); return await response.json(); } /** * Holt CSRF-Token */ getCSRFToken() { const token = document.querySelector('meta[name="csrf-token"]'); return token ? token.getAttribute('content') : ''; } /** * Zeigt Browser-Benachrichtigung */ showNotification(title, message) { if ('Notification' in window && Notification.permission === 'granted') { new Notification(title, { body: message, icon: '/static/icons/timer-icon.png' }); } } /** * Zeigt Toast-Nachricht */ showToast(message, type = 'info') { // Implementation für Toast-Nachrichten console.log(`Toast [${type}]: ${message}`); } /** * Zeigt Fehler-Modal */ showError(message) { this.showModal('Fehler', message, 'danger'); } /** * Zeigt Modal-Dialog */ showModal(title, message, type = 'info') { // Implementation für Modal-Dialoge console.log(`Modal [${type}] ${title}: ${message}`); } /** * Behandelt Tastatur-Shortcuts */ handleKeyboardShortcuts(e) { if (e.ctrlKey || e.metaKey) { switch (e.key) { case ' ': // Strg + Leertaste e.preventDefault(); if (this.state.status === 'running') { this.pause(); } else { this.start(); } break; case 'r': // Strg + R e.preventDefault(); this.reset(); break; case 's': // Strg + S e.preventDefault(); this.stop(); break; } } } /** * Behandelt Visibility-Änderungen */ handleVisibilityChange() { if (document.hidden) { // Tab wurde versteckt this.config._wasRunning = this.state.status === 'running'; } else { // Tab wurde wieder sichtbar if (this.config.syncWithServer) { this.syncWithServer(); } } } /** * Behandelt Before-Unload-Event */ handleBeforeUnload(e) { if (this.state.status === 'running' && this.state.remaining > 0) { e.preventDefault(); e.returnValue = 'Timer läuft noch. Möchten Sie die Seite wirklich verlassen?'; return e.returnValue; } } /** * Zerstört den Timer und räumt auf */ destroy() { this.stopCountdown(); if (this.intervals.serverSync) { clearInterval(this.intervals.serverSync); } // DOM-Elemente entfernen if (this.elements.wrapper) { this.elements.wrapper.remove(); } // Event-Listener entfernen this.listeners.forEach((listener, element) => { element.removeEventListener(listener.event, listener.handler); }); console.log(`Timer '${this.config.name}' zerstört`); } } // CSS-Styles für den Timer (wird dynamisch eingefügt) const timerStyles = ` `; // Styles automatisch einfügen wenn noch nicht vorhanden if (!document.getElementById('countdown-timer-styles')) { document.head.insertAdjacentHTML('beforeend', timerStyles); } // Globale Funktionen für einfache Nutzung window.CountdownTimer = CountdownTimer; // Timer-Manager für mehrere Timer-Instanzen window.TimerManager = { timers: new Map(), create(name, options) { if (this.timers.has(name)) { console.warn(`Timer '${name}' existiert bereits`); return this.timers.get(name); } const timer = new CountdownTimer({ ...options, name: name }); this.timers.set(name, timer); return timer; }, get(name) { return this.timers.get(name); }, destroy(name) { const timer = this.timers.get(name); if (timer) { timer.destroy(); this.timers.delete(name); } }, destroyAll() { this.timers.forEach(timer => timer.destroy()); this.timers.clear(); } };