From ac1ffa07f69958cbe5d9fd1b89d297c185cd96b4 Mon Sep 17 00:00:00 2001 From: data Date: Mon, 23 Feb 2026 11:09:53 +0100 Subject: [PATCH] feat(v1.2.0): Benachrichtigungston, Animationen und verbesserte Farben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Benachrichtigungston (Web Audio API) bei neuen Nachrichten - Shake & Bounce Animation bei neuen Benachrichtigungen - FAB-Farbschema: Grau (leer), Rot (Nachrichten), Pulsierend (dringend) - Panel-Header in Dolibarr-Blau statt Lila - FAB wirklich verschiebbar mit click vs. drag Erkennung - Grab-Cursor für visuelles Drag-Feedback - Position-Persistenz im localStorage Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 16 +++ class/actions_globalnotify.class.php | 12 +- core/modules/modGlobalNotify.class.php | 2 +- css/globalnotify.css | 63 +++++++-- js/globalnotify.js | 183 +++++++++++++++++++++++-- 5 files changed, 255 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f25fd9..471db11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert. +## [1.2.0] - 2026-02-23 + +### Neu +- **Benachrichtigungston**: Bei neuen Nachrichten ertönt ein dezenter Doppel-Ton +- **Shake-Animation**: FAB wackelt und springt bei neuen Benachrichtigungen +- **Verbesserte Farbgebung**: + - Grau: Keine Benachrichtigungen + - Rot: Benachrichtigungen vorhanden + - Pulsierend rot: Dringende Benachrichtigungen (Fehler/Aktionen) +- **Drag-Cursor**: Visuelles Feedback beim Ziehen des FAB + +### Verbessert +- Panel-Header in Dolibarr-Blau statt Lila +- FAB-Button jetzt wirklich verschiebbar (click vs. drag Erkennung) +- Position wird im localStorage gespeichert + ## [1.1.0] - 2026-02-23 ### Neu diff --git a/class/actions_globalnotify.class.php b/class/actions_globalnotify.class.php index e33d37f..31d671f 100644 --- a/class/actions_globalnotify.class.php +++ b/class/actions_globalnotify.class.php @@ -97,8 +97,16 @@ class ActionsGlobalNotify extends CommonHookActions // Floating button (always visible, bottom-left corner) $html .= '
'; - // Draggable handle + toggle button - $html .= '
'; + // Draggable FAB button (click to open, drag to move) + // Gray when empty, red when has notifications, pulsing when urgent + $fabClasses = 'globalnotify-fab'; + if ($unreadCount > 0) { + $fabClasses .= ' globalnotify-fab-active'; + } + if ($hasUrgent) { + $fabClasses .= ' globalnotify-fab-urgent'; + } + $html .= '
'; $html .= ''; if ($unreadCount > 0) { $html .= ''.$unreadCount.''; diff --git a/core/modules/modGlobalNotify.class.php b/core/modules/modGlobalNotify.class.php index cb97c6d..f45f1ee 100644 --- a/core/modules/modGlobalNotify.class.php +++ b/core/modules/modGlobalNotify.class.php @@ -42,7 +42,7 @@ class modGlobalNotify extends DolibarrModules $this->descriptionlong = "Provides a unified notification bell in the top bar that collects and displays alerts from any module (cron errors, warnings, action required, etc.)"; $this->editor_name = 'Data IT Solution'; $this->editor_url = 'https://data-it-solution.de'; - $this->version = '1.1.0'; + $this->version = '1.2.0'; $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); $this->picto = 'bell'; diff --git a/css/globalnotify.css b/css/globalnotify.css index 066ed22..02c38ec 100644 --- a/css/globalnotify.css +++ b/css/globalnotify.css @@ -21,30 +21,44 @@ width: 50px; height: 50px; border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: #6c757d; /* Dolibarr gray - no notifications */ color: white; display: flex; align-items: center; justify-content: center; - cursor: pointer; - box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); - transition: transform 0.2s, box-shadow 0.2s; + cursor: grab; + box-shadow: 0 4px 15px rgba(108, 117, 125, 0.4); + transition: transform 0.2s, box-shadow 0.2s, background 0.3s; position: relative; } +.globalnotify-fab:active { + cursor: grabbing; +} + .globalnotify-fab:hover { transform: scale(1.1); - box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); + box-shadow: 0 6px 20px rgba(108, 117, 125, 0.5); +} + +/* Has notifications - red */ +.globalnotify-fab-active { + background: #e74c3c; + box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4); +} + +.globalnotify-fab-active:hover { + box-shadow: 0 6px 20px rgba(231, 76, 60, 0.5); } .globalnotify-fab .fa-bell { font-size: 20px; } -/* Urgent state - pulsing */ +/* Urgent state - pulsing red */ .globalnotify-fab-urgent { animation: globalnotify-pulse 2s infinite; - background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); + background: #c0392b; box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4); } @@ -61,6 +75,37 @@ } } +/* New notification shake animation */ +.globalnotify-fab-shake { + animation: globalnotify-shake 0.8s ease-in-out; +} + +@keyframes globalnotify-shake { + 0%, 100% { transform: rotate(0deg); } + 10% { transform: rotate(-15deg); } + 20% { transform: rotate(15deg); } + 30% { transform: rotate(-15deg); } + 40% { transform: rotate(15deg); } + 50% { transform: rotate(-10deg); } + 60% { transform: rotate(10deg); } + 70% { transform: rotate(-5deg); } + 80% { transform: rotate(5deg); } + 90% { transform: rotate(0deg); } +} + +/* Bounce animation for new notifications */ +.globalnotify-fab-bounce { + animation: globalnotify-bounce 0.6s ease; +} + +@keyframes globalnotify-bounce { + 0% { transform: scale(1); } + 30% { transform: scale(1.3); } + 50% { transform: scale(0.9); } + 70% { transform: scale(1.1); } + 100% { transform: scale(1); } +} + /* Badge on FAB */ .globalnotify-fab-badge { position: absolute; @@ -95,13 +140,13 @@ flex-direction: column; } -/* Panel Header */ +/* Panel Header - Dolibarr style */ .globalnotify-panel-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 15px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: #263c5c; /* Dolibarr dark blue */ color: white; cursor: move; user-select: none; diff --git a/js/globalnotify.js b/js/globalnotify.js index 2237121..699fcd0 100644 --- a/js/globalnotify.js +++ b/js/globalnotify.js @@ -237,16 +237,22 @@ var GlobalNotify = { }, /** - * Initialize dragging + * Initialize dragging - FAB button is draggable */ initDrag: function() { var widget = document.getElementById('globalnotify-widget'); - var handle = document.getElementById('globalnotify-drag-handle'); + var fab = document.getElementById('globalnotify-fab'); - if (!widget || !handle) return; + if (!widget || !fab) return; - handle.addEventListener('mousedown', function(e) { - if (e.target.closest('.globalnotify-action-link')) return; + var dragStartTime = 0; + var dragStartPos = { x: 0, y: 0 }; + + // Make FAB draggable + fab.addEventListener('mousedown', function(e) { + e.preventDefault(); + dragStartTime = Date.now(); + dragStartPos = { x: e.clientX, y: e.clientY }; GlobalNotify.isDragging = true; var rect = widget.getBoundingClientRect(); @@ -255,6 +261,7 @@ var GlobalNotify = { y: e.clientY - rect.top }; document.body.style.userSelect = 'none'; + fab.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', function(e) { @@ -273,23 +280,159 @@ var GlobalNotify = { widget.style.right = 'auto'; }); - document.addEventListener('mouseup', function() { + document.addEventListener('mouseup', function(e) { + if (!GlobalNotify.isDragging) return; + GlobalNotify.isDragging = false; document.body.style.userSelect = ''; + fab.style.cursor = 'pointer'; + + // Only toggle if it was a click (not a drag) + var timeDiff = Date.now() - dragStartTime; + var distX = Math.abs(e.clientX - dragStartPos.x); + var distY = Math.abs(e.clientY - dragStartPos.y); + + // If moved less than 5px and less than 200ms, treat as click + if (distX < 5 && distY < 5 && timeDiff < 200) { + GlobalNotify.toggle(); + } + + // Save position to localStorage + GlobalNotify.savePosition(); }); + + // Load saved position + this.loadPosition(); }, + /** + * Save widget position to localStorage + */ + savePosition: function() { + var widget = document.getElementById('globalnotify-widget'); + if (!widget) return; + + var pos = { + left: widget.style.left, + top: widget.style.top, + bottom: widget.style.bottom + }; + try { + localStorage.setItem('globalnotify_position', JSON.stringify(pos)); + } catch (e) {} + }, + + /** + * Load widget position from localStorage + */ + loadPosition: function() { + var widget = document.getElementById('globalnotify-widget'); + if (!widget) return; + + try { + var saved = localStorage.getItem('globalnotify_position'); + if (saved) { + var pos = JSON.parse(saved); + if (pos.left && pos.left !== 'auto') { + widget.style.left = pos.left; + widget.style.bottom = 'auto'; + } + if (pos.top && pos.top !== 'auto') { + widget.style.top = pos.top; + widget.style.bottom = 'auto'; + } + } + } catch (e) {} + }, + + /** + * Track last known count for new notification detection + */ + lastKnownCount: 0, + /** * Refresh notifications via AJAX */ refresh: function() { this.ajaxCall('getcount', {}, function(data) { if (data.success) { - GlobalNotify.updateBadgeFromServer(data.count); + var oldCount = GlobalNotify.lastKnownCount; + var newCount = data.count; + + // Check if new notifications arrived + if (newCount > oldCount && oldCount >= 0) { + GlobalNotify.playNotificationSound(); + GlobalNotify.animateFab(); + } + + GlobalNotify.lastKnownCount = newCount; + GlobalNotify.updateBadgeFromServer(newCount); } }); }, + /** + * Play notification sound + */ + playNotificationSound: function() { + try { + // Use Web Audio API for notification sound + var audioContext = new (window.AudioContext || window.webkitAudioContext)(); + var oscillator = audioContext.createOscillator(); + var gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + // Pleasant notification tone + oscillator.frequency.setValueAtTime(880, audioContext.currentTime); // A5 + oscillator.type = 'sine'; + + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.3); + + // Second tone for double-beep effect + setTimeout(function() { + var osc2 = audioContext.createOscillator(); + var gain2 = audioContext.createGain(); + osc2.connect(gain2); + gain2.connect(audioContext.destination); + osc2.frequency.setValueAtTime(1100, audioContext.currentTime); // C#6 + osc2.type = 'sine'; + gain2.gain.setValueAtTime(0.3, audioContext.currentTime); + gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2); + osc2.start(audioContext.currentTime); + osc2.stop(audioContext.currentTime + 0.2); + }, 150); + } catch (e) { + console.log('GlobalNotify: Audio not available'); + } + }, + + /** + * Animate FAB on new notification + */ + animateFab: function() { + var fab = document.getElementById('globalnotify-fab'); + if (!fab) return; + + // Add shake animation + fab.classList.add('globalnotify-fab-shake'); + + // Remove class after animation completes + setTimeout(function() { + fab.classList.remove('globalnotify-fab-shake'); + // Add bounce effect + fab.classList.add('globalnotify-fab-bounce'); + setTimeout(function() { + fab.classList.remove('globalnotify-fab-bounce'); + }, 600); + }, 800); + }, + /** * Update badge from server count */ @@ -298,6 +441,11 @@ var GlobalNotify = { var fab = document.getElementById('globalnotify-fab'); if (count > 0) { + // Add active class (red) + if (fab) { + fab.classList.add('globalnotify-fab-active'); + } + if (badge) { badge.textContent = count; } else if (fab) { @@ -306,8 +454,15 @@ var GlobalNotify = { badge.textContent = count; fab.appendChild(badge); } - } else if (badge) { - badge.remove(); + } else { + // Remove active class (back to gray) + if (fab) { + fab.classList.remove('globalnotify-fab-active'); + fab.classList.remove('globalnotify-fab-urgent'); + } + if (badge) { + badge.remove(); + } } }, @@ -317,6 +472,16 @@ var GlobalNotify = { init: function() { this.initDrag(); + // Initialize last known count from current badge + var badge = document.querySelector('.globalnotify-fab-badge'); + this.lastKnownCount = badge ? parseInt(badge.textContent) || 0 : 0; + + // Set initial FAB state based on count + var fab = document.getElementById('globalnotify-fab'); + if (fab && this.lastKnownCount > 0) { + fab.classList.add('globalnotify-fab-active'); + } + // Periodic refresh every 2 minutes setInterval(function() { GlobalNotify.refresh();