feat(v1.2.0): Benachrichtigungston, Animationen und verbesserte Farben

- 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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-23 11:09:53 +01:00
parent c65d15a86b
commit ac1ffa07f6
5 changed files with 255 additions and 21 deletions

View file

@ -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

View file

@ -97,8 +97,16 @@ class ActionsGlobalNotify extends CommonHookActions
// Floating button (always visible, bottom-left corner)
$html .= '<div id="globalnotify-widget" class="globalnotify-widget">';
// Draggable handle + toggle button
$html .= '<div id="globalnotify-fab" class="globalnotify-fab'.($hasUrgent ? ' globalnotify-fab-urgent' : '').'" onclick="GlobalNotify.toggle()" title="'.$langs->trans('Notifications').'">';
// 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 .= '<div id="globalnotify-fab" class="'.$fabClasses.'" title="'.$langs->trans('Notifications').' - Ziehen zum Verschieben">';
$html .= '<span class="fa fa-bell"></span>';
if ($unreadCount > 0) {
$html .= '<span class="globalnotify-fab-badge">'.$unreadCount.'</span>';

View file

@ -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';

View file

@ -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;

View file

@ -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();