Files
cairnquire/apps/server/internal/httpserver/static/notification-bell.js
2026-06-04 08:50:34 -04:00

141 lines
3.8 KiB
JavaScript

(function () {
'use strict';
const bell = document.querySelector('[data-notification-bell]');
if (!bell) return;
const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]');
let isOpen = false;
trigger.addEventListener('click', (e) => {
e.stopPropagation();
isOpen = !isOpen;
dropdown.hidden = !isOpen;
trigger.setAttribute('aria-expanded', String(isOpen));
if (isOpen) loadNotifications();
});
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', () => {
if (isOpen) {
isOpen = false;
dropdown.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
}
});
async function updateBell() {
try {
const res = await fetch('/api/notifications/bell');
if (!res.ok) return;
const data = await res.json();
const count = data.unreadCount || 0;
countEl.textContent = count > 99 ? '99+' : String(count);
countEl.hidden = count === 0;
bell.classList.toggle('has-unread', count > 0);
} catch (err) {
console.error('bell update', err);
}
}
async function loadNotifications() {
try {
const res = await fetch('/api/notifications?limit=20');
if (!res.ok) return;
const data = await res.json();
renderNotifications(data.notifications || []);
} catch (err) {
console.error('load notifications', err);
}
}
function renderNotifications(notifications) {
const list = dropdown.querySelector('ul');
if (!list) return;
list.innerHTML = '';
// Header
const header = document.createElement('li');
header.className = 'notification-header';
header.textContent = 'Notifications';
list.appendChild(header);
if (!notifications.length) {
const empty = document.createElement('li');
empty.className = 'notification-empty';
empty.textContent = 'No notifications';
list.appendChild(empty);
return;
}
notifications.forEach(n => {
const li = document.createElement('li');
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
li.innerHTML = `
<div class="notification-message">${escapeHtml(n.message)}</div>
<time class="notification-time">${formatDate(n.createdAt)}</time>
`;
if (!n.readAt) {
li.addEventListener('click', () => markRead(n.id));
}
list.appendChild(li);
});
// Add "Mark all read" button
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
btn.textContent = 'Mark all read';
btn.addEventListener('click', markAllRead);
footer.appendChild(btn);
list.appendChild(footer);
}
async function markRead(id) {
try {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
updateBell();
loadNotifications();
} catch (err) {
console.error('mark read', err);
}
}
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
updateBell();
loadNotifications();
} catch (err) {
console.error('mark all read', err);
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
// Listen for WebSocket notification events
if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => {
updateBell();
});
}
// Poll every 60s as fallback
setInterval(updateBell, 60000);
updateBell();
})();