const root = document.querySelector('[data-notifications]'); const toastRegion = document.querySelector('[data-toast-region]'); const levelStyles = { info: { bar: 'bg-sky-400', dot: 'bg-sky-400' }, success: { bar: 'bg-emerald-400', dot: 'bg-emerald-400' }, warning: { bar: 'bg-amber-400', dot: 'bg-amber-400' }, danger: { bar: 'bg-rose-500', dot: 'bg-rose-500' }, }; if (root && toastRegion) { const pollSeconds = 15; const toggle = root.querySelector('[data-notifications-toggle]'); const panel = root.querySelector('[data-notifications-panel]'); const list = root.querySelector('[data-notifications-list]'); const empty = root.querySelector('[data-notifications-empty]'); const badge = root.querySelector('[data-notifications-badge]'); const desktop = root.querySelector('[data-notifications-desktop]'); const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? ''; const known = new Set(); let firstLoad = true; const post = (url) => fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf, Accept: 'application/json' }, credentials: 'same-origin', }); const readUrl = (id) => root.dataset.readUrl.replace('__id__', encodeURIComponent(id)); const style = (level) => levelStyles[level] ?? levelStyles.info; const element = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; }; const openItem = async (item) => { if (!item.read) { item.read = true; await post(readUrl(item.id)).catch(() => {}); } if (item.url) window.location.assign(item.url); else refresh(); }; const renderPanel = (items, unread) => { list.replaceChildren(...items.map((item) => { const row = element('li'); const button = element('button', 'flex w-full gap-3 px-4 py-3 text-left transition hover:bg-slate-800/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white'); button.type = 'button'; button.append(element('span', `mt-1.5 size-2 shrink-0 rounded-full ${item.read ? 'bg-slate-700' : style(item.level).dot}`)); const text = element('span', 'min-w-0 flex-1'); text.append( element('span', `block text-sm ${item.read ? 'text-slate-300' : 'font-semibold text-white'}`, item.title), element('span', 'mt-0.5 block text-xs text-slate-400', item.body), element('span', 'mt-1 block text-[11px] text-slate-500', item.time), ); button.append(text); button.addEventListener('click', () => openItem(item)); row.append(button); return row; })); empty.hidden = items.length > 0; badge.hidden = unread === 0; badge.textContent = unread > 99 ? '99+' : String(unread); toggle.setAttribute('aria-label', unread ? `Notifications, ${unread} unread` : 'Notifications'); }; const dismiss = (toast) => { toast.classList.add('opacity-0', 'translate-y-2'); setTimeout(() => toast.remove(), 200); }; const showToast = (item) => { const { bar } = style(item.level); const toast = element('div', 'pointer-events-auto relative flex overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-xl shadow-black/40 transition duration-200'); toast.setAttribute('role', item.level === 'danger' ? 'alert' : 'status'); toast.append(element('span', `w-1.5 shrink-0 ${bar}`)); const body = element('button', 'min-w-0 flex-1 px-4 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-white'); body.type = 'button'; body.append(element('span', 'block text-sm font-semibold text-white', item.title), element('span', 'mt-0.5 block text-xs text-slate-300', item.body)); body.addEventListener('click', () => { dismiss(toast); openItem(item); }); const close = element('button', 'px-3 text-slate-400 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-white', '✕'); close.type = 'button'; close.setAttribute('aria-label', 'Dismiss notification'); close.addEventListener('click', () => dismiss(toast)); toast.append(body, close); toastRegion.append(toast); setTimeout(() => dismiss(toast), item.level === 'danger' ? 15000 : 8000); }; const showDesktop = (item) => { if (!('Notification' in window) || Notification.permission !== 'granted' || !document.hidden) return; const note = new Notification(item.title, { body: item.body, tag: item.id }); note.addEventListener('click', () => { window.focus(); openItem(item); }); }; const refresh = async () => { try { const response = await fetch(root.dataset.feedUrl, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); if (!response.ok) return; const { items, unread } = await response.json(); renderPanel(items, unread); items.filter((item) => !known.has(item.id)).reverse().forEach((item) => { known.add(item.id); if (!firstLoad && !item.read) { showToast(item); showDesktop(item); } }); firstLoad = false; } catch (error) { // Network blips are expected; the next poll retries. } }; const setPanel = (open) => { panel.hidden = !open; toggle.setAttribute('aria-expanded', String(open)); }; toggle.addEventListener('click', () => setPanel(panel.hidden)); document.addEventListener('click', (event) => { if (!root.contains(event.target)) setPanel(false); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') setPanel(false); }); root.querySelector('[data-notifications-read-all]').addEventListener('click', async () => { await post(root.dataset.readAllUrl).catch(() => {}); refresh(); }); if ('Notification' in window && Notification.permission === 'default') { desktop.hidden = false; root.querySelector('[data-notifications-enable-desktop]').addEventListener('click', async () => { await Notification.requestPermission(); desktop.hidden = Notification.permission !== 'default'; }); } refresh(); setInterval(() => { if (!document.hidden) refresh(); }, pollSeconds * 1000); document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); }); }