Files
cairnquire/apps/server/internal/httpserver/static/notification-bell.js

160 lines
4.7 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]');
if (!trigger || !countEl || !dropdown) return;
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';
const button = document.createElement('button');
button.type = 'button';
button.className = 'notification-item__button';
button.innerHTML = `
<div class="notification-message">${escapeHtml(n.message)}</div>
<time class="notification-time">${formatDate(n.createdAt)}</time>
`;
button.addEventListener('click', async () => {
if (!n.readAt) await markRead(n.id, false);
const target = notificationURL(n);
if (target) window.location.assign(target);
});
li.appendChild(button);
list.appendChild(li);
});
// Add "Mark all read" button
if (notifications.some(notification => !notification.readAt)) {
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, refresh = true) {
try {
const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
if (refresh) {
updateBell();
loadNotifications();
}
} catch (err) {
console.error('mark read', err);
}
}
async function markAllRead() {
try {
const response = await fetch('/api/notifications/read-all', { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
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();
}
function notificationURL(notification) {
if (notification.resourceType !== 'document' || !notification.resourceId) return '';
const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/');
return '/docs/' + path;
}
// Listen for WebSocket notification events
if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => {
updateBell();
});
}
// Poll every 60s as fallback
setInterval(updateBell, 60000);
updateBell();
})();