haikal/templates/notifications.html
2025-09-17 15:44:47 +03:00

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>