Add notification inbox, toasts and alert scanner for orders, prints and filament
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
04be9802dc
commit
ab1fdb961f
@@ -61,3 +61,5 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
import './notifications';
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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(); });
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="min-w-0 flex-1 lg:pl-64">
|
||||
<header class="flex min-h-20 items-center justify-between gap-4 border-b border-slate-800 px-6 lg:px-10">
|
||||
<div><p class="text-xs font-semibold uppercase tracking-[.2em] text-brand">Workshop dashboard</p><h1 class="text-xl font-bold">@yield('title', 'Overview')</h1></div>
|
||||
<div class="flex items-center gap-4"><span class="hidden text-sm text-slate-400 sm:inline">{{ auth()->user()->username }}</span><form method="post" action="{{ route('logout') }}">@csrf<button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Sign out</button></form></div>
|
||||
<div class="flex items-center gap-4">@include('partials.notifications')<span class="hidden text-sm text-slate-400 sm:inline">{{ auth()->user()->username }}</span><form method="post" action="{{ route('logout') }}">@csrf<button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Sign out</button></form></div>
|
||||
</header>
|
||||
<main class="flex w-full flex-col gap-7 px-6 py-8 lg:px-10">
|
||||
@if(session('status'))<div role="status" class="rounded-xl border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand">{{ session('status') }}</div>@endif
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<div class="relative" data-notifications data-feed-url="{{ route('notifications.index') }}" data-read-all-url="{{ route('notifications.read-all') }}" data-read-url="{{ route('notifications.read', '__id__') }}">
|
||||
<button type="button" data-notifications-toggle class="relative grid size-10 place-items-center rounded-lg border border-slate-700 text-slate-300 transition hover:border-brand hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="Notifications" aria-haspopup="true" aria-expanded="false">
|
||||
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span data-notifications-badge hidden class="absolute -right-1.5 -top-1.5 grid min-w-5 place-items-center rounded-full bg-brand px-1 text-[11px] font-bold leading-5 text-slate-950"></span>
|
||||
</button>
|
||||
<div data-notifications-panel hidden class="absolute right-0 z-40 mt-3 w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl shadow-black/50">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-4 py-3">
|
||||
<h2 class="text-sm font-semibold">Notifications</h2>
|
||||
<button type="button" data-notifications-read-all class="text-xs font-semibold text-brand hover:underline">Mark all read</button>
|
||||
</div>
|
||||
<ul data-notifications-list class="max-h-96 divide-y divide-slate-800 overflow-y-auto"></ul>
|
||||
<p data-notifications-empty class="px-4 py-8 text-center text-sm text-slate-400">You are all caught up.</p>
|
||||
<div data-notifications-desktop hidden class="border-t border-slate-800 px-4 py-3">
|
||||
<button type="button" data-notifications-enable-desktop class="text-xs font-semibold text-brand hover:underline">Enable desktop notifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-toast-region class="pointer-events-none fixed bottom-4 right-4 z-50 flex w-[min(22rem,calc(100vw-2rem))] flex-col gap-3" aria-live="polite" aria-atomic="false"></div>
|
||||
Reference in New Issue
Block a user