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:
parent
c65d15a86b
commit
ac1ffa07f6
5 changed files with 255 additions and 21 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -2,6 +2,22 @@
|
||||||
|
|
||||||
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
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
|
## [1.1.0] - 2026-02-23
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,16 @@ class ActionsGlobalNotify extends CommonHookActions
|
||||||
// Floating button (always visible, bottom-left corner)
|
// Floating button (always visible, bottom-left corner)
|
||||||
$html .= '<div id="globalnotify-widget" class="globalnotify-widget">';
|
$html .= '<div id="globalnotify-widget" class="globalnotify-widget">';
|
||||||
|
|
||||||
// Draggable handle + toggle button
|
// Draggable FAB button (click to open, drag to move)
|
||||||
$html .= '<div id="globalnotify-fab" class="globalnotify-fab'.($hasUrgent ? ' globalnotify-fab-urgent' : '').'" onclick="GlobalNotify.toggle()" title="'.$langs->trans('Notifications').'">';
|
// 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>';
|
$html .= '<span class="fa fa-bell"></span>';
|
||||||
if ($unreadCount > 0) {
|
if ($unreadCount > 0) {
|
||||||
$html .= '<span class="globalnotify-fab-badge">'.$unreadCount.'</span>';
|
$html .= '<span class="globalnotify-fab-badge">'.$unreadCount.'</span>';
|
||||||
|
|
|
||||||
|
|
@ -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->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_name = 'Data IT Solution';
|
||||||
$this->editor_url = 'https://data-it-solution.de';
|
$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->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
||||||
$this->picto = 'bell';
|
$this->picto = 'bell';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,30 +21,44 @@
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: #6c757d; /* Dolibarr gray - no notifications */
|
||||||
color: white;
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 15px rgba(108, 117, 125, 0.4);
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s, background 0.3s;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.globalnotify-fab:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.globalnotify-fab:hover {
|
.globalnotify-fab:hover {
|
||||||
transform: scale(1.1);
|
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 {
|
.globalnotify-fab .fa-bell {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Urgent state - pulsing */
|
/* Urgent state - pulsing red */
|
||||||
.globalnotify-fab-urgent {
|
.globalnotify-fab-urgent {
|
||||||
animation: globalnotify-pulse 2s infinite;
|
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);
|
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 */
|
/* Badge on FAB */
|
||||||
.globalnotify-fab-badge {
|
.globalnotify-fab-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -95,13 +140,13 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Panel Header */
|
/* Panel Header - Dolibarr style */
|
||||||
.globalnotify-panel-header {
|
.globalnotify-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 15px;
|
padding: 12px 15px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: #263c5c; /* Dolibarr dark blue */
|
||||||
color: white;
|
color: white;
|
||||||
cursor: move;
|
cursor: move;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
|
||||||
|
|
@ -237,16 +237,22 @@ var GlobalNotify = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize dragging
|
* Initialize dragging - FAB button is draggable
|
||||||
*/
|
*/
|
||||||
initDrag: function() {
|
initDrag: function() {
|
||||||
var widget = document.getElementById('globalnotify-widget');
|
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) {
|
var dragStartTime = 0;
|
||||||
if (e.target.closest('.globalnotify-action-link')) return;
|
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;
|
GlobalNotify.isDragging = true;
|
||||||
var rect = widget.getBoundingClientRect();
|
var rect = widget.getBoundingClientRect();
|
||||||
|
|
@ -255,6 +261,7 @@ var GlobalNotify = {
|
||||||
y: e.clientY - rect.top
|
y: e.clientY - rect.top
|
||||||
};
|
};
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
|
fab.style.cursor = 'grabbing';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mousemove', function(e) {
|
document.addEventListener('mousemove', function(e) {
|
||||||
|
|
@ -273,23 +280,159 @@ var GlobalNotify = {
|
||||||
widget.style.right = 'auto';
|
widget.style.right = 'auto';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mouseup', function() {
|
document.addEventListener('mouseup', function(e) {
|
||||||
|
if (!GlobalNotify.isDragging) return;
|
||||||
|
|
||||||
GlobalNotify.isDragging = false;
|
GlobalNotify.isDragging = false;
|
||||||
document.body.style.userSelect = '';
|
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 notifications via AJAX
|
||||||
*/
|
*/
|
||||||
refresh: function() {
|
refresh: function() {
|
||||||
this.ajaxCall('getcount', {}, function(data) {
|
this.ajaxCall('getcount', {}, function(data) {
|
||||||
if (data.success) {
|
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
|
* Update badge from server count
|
||||||
*/
|
*/
|
||||||
|
|
@ -298,6 +441,11 @@ var GlobalNotify = {
|
||||||
var fab = document.getElementById('globalnotify-fab');
|
var fab = document.getElementById('globalnotify-fab');
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
|
// Add active class (red)
|
||||||
|
if (fab) {
|
||||||
|
fab.classList.add('globalnotify-fab-active');
|
||||||
|
}
|
||||||
|
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.textContent = count;
|
badge.textContent = count;
|
||||||
} else if (fab) {
|
} else if (fab) {
|
||||||
|
|
@ -306,8 +454,15 @@ var GlobalNotify = {
|
||||||
badge.textContent = count;
|
badge.textContent = count;
|
||||||
fab.appendChild(badge);
|
fab.appendChild(badge);
|
||||||
}
|
}
|
||||||
} else if (badge) {
|
} else {
|
||||||
badge.remove();
|
// 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() {
|
init: function() {
|
||||||
this.initDrag();
|
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
|
// Periodic refresh every 2 minutes
|
||||||
setInterval(function() {
|
setInterval(function() {
|
||||||
GlobalNotify.refresh();
|
GlobalNotify.refresh();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue