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.
|
||||
|
||||
## [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
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue