/** * Notification bell runtime component. * * Works with the native
dropdown. Handles polling, * notification list rendering, mark-read and dismiss actions. */ const POLL_INTERVAL_MS = 60_000; const TYPE_ICONS = { 'user.created': 'bi-person-plus', 'user.deleted': 'bi-person-dash', 'system': 'bi-gear', }; function getCsrf() { const root = document.documentElement; return { key: root.dataset.csrfKey || '', token: root.dataset.csrfToken || '', }; } function timeAgo(dateStr) { const now = Date.now(); const then = new Date(dateStr).getTime(); const diffSec = Math.floor((now - then) / 1000); if (diffSec < 60) return '< 1m'; if (diffSec < 3600) return Math.floor(diffSec / 60) + 'm'; if (diffSec < 86400) return Math.floor(diffSec / 3600) + 'h'; return Math.floor(diffSec / 86400) + 'd'; } function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } async function fetchJson(url, options = {}) { const resp = await fetch(url, options); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.json(); } export function initNotificationBell(root = document, config = {}) { const details = root.querySelector('[data-notification-bell]'); const dropdown = root.querySelector('[data-notification-dropdown]'); if (!details || !dropdown) return { destroy() {} }; const badge = details.querySelector('[data-notification-badge]'); const list = dropdown.querySelector('[data-notification-list]'); const emptyEl = list?.querySelector('.app-empty-state'); const markAllBtn = dropdown.querySelector('[data-notification-mark-all]'); const ac = new AbortController(); const signal = ac.signal; let pollTimer = null; // --- Badge --- function updateBadge(count) { if (!badge) return; if (count > 0) { badge.textContent = count > 99 ? '99+' : String(count); badge.style.display = ''; } else { badge.textContent = ''; badge.style.display = 'none'; } } // --- Render --- function renderNotifications(notifications) { if (!list) return; // Remove rendered items, keep empty state partial list.querySelectorAll('.app-notification-item').forEach(el => el.remove()); if (notifications.length === 0) { if (emptyEl) emptyEl.style.display = ''; return; } if (emptyEl) emptyEl.style.display = 'none'; const frag = document.createDocumentFragment(); for (const n of notifications) { frag.appendChild(createNotificationItem(n)); } list.insertBefore(frag, emptyEl); } function createNotificationItem(n) { const item = document.createElement('div'); item.className = 'app-notification-item' + (n.is_read === 0 ? ' app-notification-item--unread' : ''); item.dataset.notificationId = n.id; const iconClass = TYPE_ICONS[n.type] || 'bi-bell'; const bodyHtml = n.body ? `

${escapeHtml(n.body)}

` : ''; item.innerHTML = `` + `
` + `

${escapeHtml(n.title)}

` + bodyHtml + `${escapeHtml(timeAgo(n.created))}` + `
` + `
` + (n.is_read === 0 ? `` : '') + `` + `
`; // Click on item navigates if link exists item.addEventListener('click', (e) => { if (e.target.closest('[data-action]')) return; if (n.link) { if (n.is_read === 0) markRead(n.id); const base = document.querySelector('base')?.getAttribute('href') || '/'; window.location.href = base + n.link; } }, { signal }); // Action buttons item.addEventListener('click', (e) => { const actionBtn = e.target.closest('[data-action]'); if (!actionBtn) return; e.stopPropagation(); const action = actionBtn.dataset.action; if (action === 'mark-read') markRead(n.id); if (action === 'dismiss') dismiss(n.id); }, { signal }); return item; } // --- API --- async function loadNotifications() { try { const data = await fetchJson('notifications/list-data', { signal }); if (data.ok) { renderNotifications(data.notifications || []); updateBadge(data.unread_count ?? 0); } } catch { /* swallow */ } } async function pollUnreadCount() { try { const data = await fetchJson('notifications/unread-count-data', { signal }); if (data.ok) { updateBadge(data.unread_count ?? 0); } } catch { /* swallow */ } } async function markRead(id) { try { const csrf = getCsrf(); const body = new URLSearchParams(); body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); const data = await fetchJson('notifications/mark-read-data', { method: 'POST', body, signal, }); if (data.ok) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); } } catch { /* swallow */ } } async function markAllRead() { try { const csrf = getCsrf(); const body = new URLSearchParams(); body.set('all', '1'); if (csrf.key) body.set(csrf.key, csrf.token); const data = await fetchJson('notifications/mark-read-data', { method: 'POST', body, signal, }); if (data.ok) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); } } catch { /* swallow */ } } async function dismiss(id) { try { const csrf = getCsrf(); const body = new URLSearchParams(); body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); const data = await fetchJson('notifications/delete-data', { method: 'POST', body, signal, }); if (data.ok) { updateBadge(data.unread_count ?? 0); const el = list?.querySelector(`[data-notification-id="${id}"]`); if (el) { el.remove(); if (list && !list.querySelector('.app-notification-item') && emptyEl) { emptyEl.style.display = ''; } } } } catch { /* swallow */ } } // --- Events --- details.addEventListener('toggle', () => { if (details.open) loadNotifications(); }, { signal }); if (markAllBtn) { markAllBtn.addEventListener('click', (e) => { e.stopPropagation(); markAllRead(); }, { signal }); } // --- Polling --- function startPolling() { stopPolling(); pollTimer = setInterval(() => { if (document.visibilityState !== 'hidden') { pollUnreadCount(); } }, POLL_INTERVAL_MS); } function stopPolling() { if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; } } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { pollUnreadCount(); startPolling(); } else { stopPolling(); } }, { signal }); startPolling(); return { destroy() { ac.abort(); stopPolling(); }, }; }