feat: add notifications module with bell UI, event listeners, and cleanup job
In-app notification system with topbar bell icon, dropdown panel, mark-as-read/dismiss actions, and scheduled cleanup of old read notifications. Includes event listeners for user.created and user.deleted events, authorization policy, and full test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
264
modules/notifications/web/js/components/app-notification-bell.js
Normal file
264
modules/notifications/web/js/components/app-notification-bell.js
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Notification bell runtime component.
|
||||
*
|
||||
* Works with the native <details> 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 ? `<p class="app-notification-item__body">${escapeHtml(n.body)}</p>` : '';
|
||||
|
||||
item.innerHTML =
|
||||
`<span class="app-notification-item__icon"><i class="bi ${escapeHtml(iconClass)}"></i></span>` +
|
||||
`<div class="app-notification-item__content">` +
|
||||
`<p class="app-notification-item__title">${escapeHtml(n.title)}</p>` +
|
||||
bodyHtml +
|
||||
`<span class="app-notification-item__time">${escapeHtml(timeAgo(n.created))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="app-notification-item__actions">` +
|
||||
(n.is_read === 0 ? `<button type="button" class="app-notification-item__action-btn" data-action="mark-read" title="${escapeHtml(dropdown.dataset.labelMarkRead || '')}"><i class="bi bi-check2"></i></button>` : '') +
|
||||
`<button type="button" class="app-notification-item__action-btn" data-action="dismiss" title="${escapeHtml(dropdown.dataset.labelDismiss || '')}"><i class="bi bi-x"></i></button>` +
|
||||
`</div>`;
|
||||
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user