';
+ // 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();