dolibarr.globalnotify/js/globalnotify.js
data ac1ffa07f6 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>
2026-02-23 11:09:53 +01:00

495 lines
12 KiB
JavaScript

/**
* GlobalNotify JavaScript
* Floating messenger-style notification widget
*/
var GlobalNotify = {
isOpen: false,
isDragging: false,
dragOffset: { x: 0, y: 0 },
/**
* Toggle panel visibility
*/
toggle: function() {
var panel = document.getElementById('globalnotify-panel');
if (!panel) return;
if (this.isOpen) {
this.close();
} else {
this.open();
}
},
/**
* Open panel
*/
open: function() {
var panel = document.getElementById('globalnotify-panel');
if (!panel) return;
panel.style.display = 'flex';
this.isOpen = true;
// Close when clicking outside
setTimeout(function() {
document.addEventListener('click', GlobalNotify.handleOutsideClick);
}, 10);
},
/**
* Close panel
*/
close: function() {
var panel = document.getElementById('globalnotify-panel');
if (!panel) return;
panel.style.display = 'none';
this.isOpen = false;
document.removeEventListener('click', GlobalNotify.handleOutsideClick);
},
/**
* Handle click outside
*/
handleOutsideClick: function(event) {
var widget = document.getElementById('globalnotify-widget');
if (widget && !widget.contains(event.target)) {
GlobalNotify.close();
}
},
/**
* Toggle history section
*/
toggleHistory: function() {
var toggle = document.querySelector('.globalnotify-history-toggle');
var history = document.getElementById('globalnotify-history');
if (!toggle || !history) return;
if (history.classList.contains('open')) {
history.classList.remove('open');
toggle.classList.remove('open');
} else {
history.classList.add('open');
toggle.classList.add('open');
}
},
/**
* Toggle item (mark as read/unread)
*/
toggleItem: function(notificationId, event) {
if (event) {
event.stopPropagation();
}
var item = document.querySelector('.globalnotify-item[data-id="' + notificationId + '"]');
if (!item) return;
// Visual feedback
item.style.opacity = '0.5';
// AJAX call to toggle read status
this.ajaxCall('dismiss', { id: notificationId }, function(data) {
if (data.success) {
// Move item to history or remove
item.style.transition = 'opacity 0.3s, transform 0.3s';
item.style.opacity = '0';
item.style.transform = 'translateX(-100%)';
setTimeout(function() {
item.remove();
GlobalNotify.updateBadge();
GlobalNotify.updateCounts();
}, 300);
} else {
item.style.opacity = '1';
}
});
},
/**
* Go to action URL and mark as read
*/
goToAction: function(notificationId, url) {
// Mark as read first
this.ajaxCall('dismiss', { id: notificationId }, function() {
// Navigate to URL
window.location.href = url;
});
},
/**
* Mark all as read
*/
markAllRead: function() {
var section = document.querySelector('.globalnotify-section');
var items = section ? section.querySelectorAll('.globalnotify-item') : [];
// Visual feedback
items.forEach(function(item) {
item.style.opacity = '0.5';
});
this.ajaxCall('markallread', {}, function(data) {
if (data.success) {
// Clear the section
if (section) {
section.innerHTML = '<div class="globalnotify-empty"><span class="fa fa-check-circle"></span><br>Keine Benachrichtigungen</div>';
}
GlobalNotify.updateBadge();
GlobalNotify.updateCounts();
}
});
},
/**
* Update badge on FAB
*/
updateBadge: function() {
var badge = document.querySelector('.globalnotify-fab-badge');
var items = document.querySelectorAll('.globalnotify-section .globalnotify-item');
var count = items.length;
if (count > 0) {
if (badge) {
badge.textContent = count;
} else {
// Create badge
var fab = document.getElementById('globalnotify-fab');
if (fab) {
badge = document.createElement('span');
badge.className = 'globalnotify-fab-badge';
badge.textContent = count;
fab.appendChild(badge);
}
}
} else if (badge) {
badge.remove();
}
// Update urgent state
var fab = document.getElementById('globalnotify-fab');
if (fab) {
var hasUrgent = document.querySelector('.globalnotify-item-error, .globalnotify-item-action');
if (hasUrgent && count > 0) {
fab.classList.add('globalnotify-fab-urgent');
} else {
fab.classList.remove('globalnotify-fab-urgent');
}
}
},
/**
* Update header counts
*/
updateCounts: function() {
var title = document.querySelector('.globalnotify-panel-title');
if (!title) return;
var unreadItems = document.querySelectorAll('.globalnotify-section .globalnotify-item');
var historyItems = document.querySelectorAll('.globalnotify-history .globalnotify-item');
var unreadCount = unreadItems.length;
var totalCount = unreadCount + historyItems.length;
title.textContent = 'Benachrichtigungen (' + unreadCount + '/' + totalCount + ')';
// Hide/show mark all link
var markAll = document.querySelector('.globalnotify-panel-actions .globalnotify-action-link');
if (markAll) {
markAll.style.display = unreadCount > 0 ? '' : 'none';
}
},
/**
* AJAX helper
*/
ajaxCall: function(action, params, callback) {
var url = (typeof DOL_URL_ROOT !== 'undefined' ? DOL_URL_ROOT : '') + '/custom/globalnotify/ajax/action.php';
var body = 'action=' + encodeURIComponent(action);
for (var key in params) {
body += '&' + key + '=' + encodeURIComponent(params[key]);
}
if (typeof TOKEN !== 'undefined') {
body += '&token=' + TOKEN;
}
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (callback) callback(data);
})
.catch(function(error) {
console.error('GlobalNotify error:', error);
if (callback) callback({ success: false, error: error });
});
},
/**
* Initialize dragging - FAB button is draggable
*/
initDrag: function() {
var widget = document.getElementById('globalnotify-widget');
var fab = document.getElementById('globalnotify-fab');
if (!widget || !fab) 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();
GlobalNotify.dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
document.body.style.userSelect = 'none';
fab.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', function(e) {
if (!GlobalNotify.isDragging) return;
var x = e.clientX - GlobalNotify.dragOffset.x;
var y = e.clientY - GlobalNotify.dragOffset.y;
// Keep within viewport
x = Math.max(0, Math.min(x, window.innerWidth - 60));
y = Math.max(0, Math.min(y, window.innerHeight - 60));
widget.style.left = x + 'px';
widget.style.top = y + 'px';
widget.style.bottom = 'auto';
widget.style.right = 'auto';
});
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) {
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
*/
updateBadgeFromServer: function(count) {
var badge = document.querySelector('.globalnotify-fab-badge');
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) {
badge = document.createElement('span');
badge.className = 'globalnotify-fab-badge';
badge.textContent = count;
fab.appendChild(badge);
}
} 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();
}
}
},
/**
* Initialize
*/
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();
}, 120000);
}
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
GlobalNotify.init();
});