- Add cleanupDisabledCronNotifications() method - Automatically mark notifications as read when cronjob is disabled - Fixes issue where "Cron-Job verpasst" kept reappearing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
495 lines
12 KiB
JavaScript
Executable file
495 lines
12 KiB
JavaScript
Executable file
/**
|
|
* 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();
|
|
});
|