307 lines
12 KiB
HTML
307 lines
12 KiB
HTML
{% load i18n %}
|
|
<style>
|
|
.fade-out {
|
|
animation: fadeOut 1s ease-out forwards;
|
|
}
|
|
|
|
@keyframes fadeOut {
|
|
from {
|
|
opacity: 1;
|
|
}
|
|
to {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
|
|
</style>
|
|
<li class="nav-item dropdown">
|
|
<!-- Notification counter -->
|
|
<div class="notification-count">
|
|
{% if notifications_ %}
|
|
<span class="badge bg-danger rounded-pill"
|
|
id="notification-counter"
|
|
style="position: absolute;
|
|
top: 8px;
|
|
right: 3px;
|
|
font-size: 0.50rem">{{ notifications_.count }}</span>
|
|
{% else %}
|
|
<span class="badge bg-danger rounded-pill d-none"
|
|
id="notification-counter"
|
|
style="position: absolute;
|
|
top: 8px;
|
|
right: 3px;
|
|
font-size: 0.50rem">0</span>
|
|
{% endif %}
|
|
</div>
|
|
<!-- Bell icon -->
|
|
<a class="nav-link"
|
|
href="{% url 'fetch_notifications' %}"
|
|
style="min-width: 2.25rem"
|
|
role="button"
|
|
data-bs-toggle="dropdown"
|
|
aria-haspopup="true"
|
|
aria-expanded="false"
|
|
data-bs-auto-close="outside">
|
|
<span class="d-block" style="height:20px;width:20px;">
|
|
<span data-feather="bell" style="height:20px;width:20px;"></span>
|
|
</span>
|
|
</a>
|
|
<!-- Dropdown menu -->
|
|
<div class="dropdown-menu dropdown-menu-end notification-dropdown-menu py-1 shadow border navbar-dropdown-caret"
|
|
id="navbarDropdownNotfication">
|
|
<div class="card position-relative border-0">
|
|
<div class="card-header p-2">
|
|
<div class="d-flex justify-content-between">
|
|
<h5 class="text-body-emphasis mb-0">{{ _("Notifications") }}</h5>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="scrollbar-overlay"
|
|
style="height: 27rem"
|
|
id="notifications-container">
|
|
{% for notification in notifications_ %}
|
|
<div class="px-2 px-sm-3 py-3 notification-card position-relative read border-bottom"
|
|
data-notification-id="{{ notification.id }}">
|
|
<!-- Notification content -->
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<div class="card-footer p-0 border-top border-translucent border-0">
|
|
<div class="my-3 text-center fw-bold fs-9 text-body-tertiary text-opactity-85">
|
|
<a class="fw-bolder" href="{% url 'notifications_history' %}">{% trans "Notification history" %}<i class="fa-solid fa-history ms-1"></i></a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
let Toast = Swal.mixin({
|
|
toast: true,
|
|
position: "top-end",
|
|
showConfirmButton: false,
|
|
timer: 2000,
|
|
timerProgressBar: false,
|
|
didOpen: (toast) => {
|
|
toast.onmouseenter = Swal.stopTimer;
|
|
toast.onmouseleave = Swal.resumeTimer;
|
|
}
|
|
});
|
|
|
|
{% with last_notif=notifications_|last %}
|
|
let lastNotificationId = {{ last_notif.id|default:0 }};
|
|
{% endwith %}
|
|
|
|
let seenNotificationIds = new Set();
|
|
let counter = document.getElementById('notification-counter');
|
|
let notificationsContainer = document.getElementById('notifications-container');
|
|
let eventSource = null;
|
|
const notificationSound = new Audio('/static/sounds/tone.wav');
|
|
notificationSound.volume = 0.3;
|
|
|
|
let initialUnreadCount = {{ notifications_.count|default:0 }};
|
|
updateCounter(initialUnreadCount);
|
|
|
|
fetchInitialNotifications();
|
|
|
|
function fetchInitialNotifications() {
|
|
fetch("{% url 'fetch_notifications' %}")
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.notifications && data.notifications.length > 0) {
|
|
lastNotificationId = data.notifications[0].id;
|
|
seenNotificationIds = new Set();
|
|
|
|
let unreadCount = 0;
|
|
data.notifications.forEach(notification => {
|
|
seenNotificationIds.add(notification.id);
|
|
if (!notification.is_read) unreadCount++;
|
|
});
|
|
|
|
renderNotifications(data.notifications);
|
|
updateCounter(unreadCount);
|
|
}
|
|
// Always connect SSE after initial load
|
|
setTimeout(() => {
|
|
connectSSE();
|
|
}, 1000);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching initial notifications:', error);
|
|
connectSSE();
|
|
});
|
|
}
|
|
|
|
function connectSSE() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
|
|
// ✅ FIXED URL HERE
|
|
eventSource = new EventSource("/sse/notifications/?last_id=" + lastNotificationId);
|
|
|
|
eventSource.addEventListener('notification', function(e) {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (seenNotificationIds.has(data.id)) return;
|
|
seenNotificationIds.add(data.id);
|
|
|
|
if (data.id > lastNotificationId) {
|
|
lastNotificationId = data.id;
|
|
}
|
|
|
|
updateCounter('increment');
|
|
|
|
if (!notificationsContainer) {
|
|
console.warn("Notification container missing, can't render SSE event");
|
|
return;
|
|
}
|
|
|
|
const notificationElement = createNotificationElement(data);
|
|
notificationsContainer.insertAdjacentHTML('afterbegin', notificationElement);
|
|
|
|
notificationSound.currentTime = 0;
|
|
notificationSound.play().catch(e => {
|
|
console.log("Audio play failed - may need user interaction first:", e);
|
|
});
|
|
|
|
Toast.fire({
|
|
icon: 'info',
|
|
html: `${data.message}`
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error processing notification:', error);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('error', function(e) {
|
|
console.error('SSE connection error:', e);
|
|
eventSource.close();
|
|
setTimeout(connectSSE, 8000);
|
|
});
|
|
}
|
|
|
|
function renderNotifications(notifications) {
|
|
if (!notificationsContainer) return;
|
|
|
|
let html = '';
|
|
notifications.forEach(notification => {
|
|
html += createNotificationElement(notification);
|
|
});
|
|
|
|
notificationsContainer.innerHTML = html;
|
|
}
|
|
|
|
function createNotificationElement(data) {
|
|
const isRead = data.is_read ? 'read' : 'unread';
|
|
return `
|
|
<div class="px-2 px-sm-3 py-3 notification-card position-relative ${isRead} border-bottom" data-notification-id="${data.id}">
|
|
<div class="d-flex align-items-center justify-content-between position-relative">
|
|
<div class="d-flex">
|
|
<div class="flex-1 me-sm-3">
|
|
<h4 class="fs-9 text-body-emphasis">System</h4>
|
|
<p class="fs-9 text-body-highlight mb-2 mb-sm-3 fw-normal">
|
|
<span class="me-1 fs-10">💬</span>${data.message}<span class="ms-2 text-body-quaternary text-opacity-75 fw-bold fs-10">Just now</span>
|
|
</p>
|
|
<p class="text-body-secondary fs-9 mb-0">
|
|
<span class="me-1 fas fa-clock"></span><span class="fw-bold">${new Date(data.created).toLocaleTimeString()}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="dropdown notification-dropdown">
|
|
<button class="btn fs-10 btn-sm dropdown-toggle dropdown-caret-none transition-none" type="button" data-bs-toggle="dropdown">
|
|
<span class="fas fa-ellipsis-h fs-10 text-body"></span>
|
|
</button>
|
|
<div class="dropdown-menu py-2">
|
|
<a class="dropdown-item mark-as-read" href="#" data-notification-id="${data.id}">Mark as read<i class="fa-solid fa-check ms-1"></i></a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateCounter(action) {
|
|
if (!counter) {
|
|
counter = document.getElementById('notification-counter');
|
|
if (!counter) {
|
|
const notificationCountDiv = document.querySelector('.notification-count');
|
|
if (notificationCountDiv) {
|
|
notificationCountDiv.innerHTML = `
|
|
<span class="badge bg-danger rounded-pill" id="notification-counter" style="position: absolute; top: 8px; right: 3px; font-size: 0.50rem;">0</span>
|
|
`;
|
|
counter = document.getElementById('notification-counter');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!counter) return;
|
|
|
|
let currentCount = parseInt(counter.textContent) || 0;
|
|
|
|
if (action === 'increment') {
|
|
currentCount += 1;
|
|
} else if (action === 'decrement') {
|
|
currentCount = Math.max(0, currentCount - 1);
|
|
} else if (typeof action === 'number') {
|
|
currentCount = action;
|
|
}
|
|
|
|
counter.textContent = currentCount;
|
|
|
|
if (currentCount > 0) {
|
|
counter.classList.remove('d-none');
|
|
} else {
|
|
counter.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
document.getElementById('mark-all-read')?.addEventListener('click', function() {
|
|
fetch("{% url 'mark_all_notifications_as_read' %}", {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': '{{ csrf_token }}',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
}).then(response => {
|
|
if (response.ok) {
|
|
updateCounter(0);
|
|
document.querySelectorAll('.notification-card').forEach(card => {
|
|
card.classList.remove('unread');
|
|
card.classList.add('read');
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.classList.contains('mark-as-read')) {
|
|
e.preventDefault();
|
|
const notificationId = e.target.getAttribute('data-notification-id');
|
|
fetch(`/notifications/${notificationId}/mark_as_read/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': '{{ csrf_token }}',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
}).then(response => {
|
|
if (response.ok) {
|
|
const notificationCard = document.querySelector(`[data-notification-id="${notificationId}"]`);
|
|
if (notificationCard) {
|
|
notificationCard.classList.remove('unread');
|
|
notificationCard.classList.add('read');
|
|
updateCounter('decrement');
|
|
notificationCard.closest('.notification-card').classList.add('fade-out');
|
|
setTimeout(() => {
|
|
notificationCard.closest('.notification-card').remove();
|
|
}, 1000);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
</script> |