Files
HickLab3D_Dashboard/resources/js/notifications.js
T
HickmeisterandClaude Sonnet 5.5 ab1fdb961f
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Add notification inbox, toasts and alert scanner for orders, prints and filament
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 21:02:35 +01:00

144 lines
6.7 KiB
JavaScript

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(); });
}