511 lines
12 KiB
TypeScript
511 lines
12 KiB
TypeScript
/**
|
|
* <rstack-notification-bell> — Notification bell with dropdown panel.
|
|
*
|
|
* Shows unread count badge over a bell icon. Click opens a dropdown
|
|
* with the notification list. Listens for real-time WS events and
|
|
* polls /api/notifications/count as a fallback.
|
|
*/
|
|
|
|
import { getSession } from "./rstack-identity";
|
|
|
|
const POLL_INTERVAL = 30_000; // 30s fallback poll
|
|
|
|
interface NotificationItem {
|
|
id: string;
|
|
category: string;
|
|
eventType: string;
|
|
title: string;
|
|
body: string | null;
|
|
spaceSlug: string | null;
|
|
actorUsername: string | null;
|
|
actionUrl: string | null;
|
|
createdAt: string;
|
|
read: boolean;
|
|
}
|
|
|
|
export class RStackNotificationBell extends HTMLElement {
|
|
#shadow: ShadowRoot;
|
|
#unreadCount = 0;
|
|
#notifications: NotificationItem[] = [];
|
|
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
#open = false;
|
|
#loading = false;
|
|
|
|
constructor() {
|
|
super();
|
|
this.#shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#render();
|
|
this.#fetchCount();
|
|
this.#pollTimer = setInterval(() => this.#fetchCount(), POLL_INTERVAL);
|
|
|
|
// Listen for real-time WS notifications
|
|
window.addEventListener("rspace-notification", this.#onWsNotification as EventListener);
|
|
|
|
// Re-render on auth change
|
|
document.addEventListener("auth-change", this.#onAuthChange);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.#pollTimer) clearInterval(this.#pollTimer);
|
|
window.removeEventListener("rspace-notification", this.#onWsNotification as EventListener);
|
|
document.removeEventListener("auth-change", this.#onAuthChange);
|
|
}
|
|
|
|
#onAuthChange = () => {
|
|
this.#unreadCount = 0;
|
|
this.#notifications = [];
|
|
this.#open = false;
|
|
this.#render();
|
|
this.#fetchCount();
|
|
};
|
|
|
|
#onWsNotification = (e: CustomEvent) => {
|
|
const data = e.detail;
|
|
if (!data?.notification) return;
|
|
|
|
this.#unreadCount = data.unreadCount ?? this.#unreadCount + 1;
|
|
// Prepend to list if panel is loaded
|
|
this.#notifications.unshift({
|
|
...data.notification,
|
|
read: false,
|
|
});
|
|
this.#render();
|
|
};
|
|
|
|
#getToken(): string | null {
|
|
const session = getSession();
|
|
return session?.accessToken ?? null;
|
|
}
|
|
|
|
async #fetchCount() {
|
|
const token = this.#getToken();
|
|
if (!token) {
|
|
this.#unreadCount = 0;
|
|
this.#render();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/notifications/count", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
this.#unreadCount = data.unreadCount || 0;
|
|
this.#render();
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}
|
|
|
|
async #fetchNotifications() {
|
|
const token = this.#getToken();
|
|
if (!token) return;
|
|
|
|
this.#loading = true;
|
|
this.#render();
|
|
|
|
try {
|
|
const res = await fetch("/api/notifications?limit=20", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
this.#notifications = data.notifications || [];
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
this.#loading = false;
|
|
this.#render();
|
|
}
|
|
|
|
async #markRead(id: string) {
|
|
const token = this.#getToken();
|
|
if (!token) return;
|
|
|
|
try {
|
|
await fetch(`/api/notifications/${id}/read`, {
|
|
method: "PATCH",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
|
|
const n = this.#notifications.find(n => n.id === id);
|
|
if (n && !n.read) {
|
|
n.read = true;
|
|
this.#unreadCount = Math.max(0, this.#unreadCount - 1);
|
|
this.#render();
|
|
}
|
|
}
|
|
|
|
async #markAllRead() {
|
|
const token = this.#getToken();
|
|
if (!token) return;
|
|
|
|
try {
|
|
await fetch("/api/notifications/read-all", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: "{}",
|
|
});
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
|
|
this.#notifications.forEach(n => n.read = true);
|
|
this.#unreadCount = 0;
|
|
this.#render();
|
|
}
|
|
|
|
async #dismiss(id: string) {
|
|
const token = this.#getToken();
|
|
if (!token) return;
|
|
|
|
try {
|
|
await fetch(`/api/notifications/${id}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
|
|
const idx = this.#notifications.findIndex(n => n.id === id);
|
|
if (idx >= 0) {
|
|
const n = this.#notifications[idx];
|
|
if (!n.read) this.#unreadCount = Math.max(0, this.#unreadCount - 1);
|
|
this.#notifications.splice(idx, 1);
|
|
this.#render();
|
|
}
|
|
}
|
|
|
|
#togglePanel() {
|
|
this.#open = !this.#open;
|
|
if (this.#open && this.#notifications.length === 0) {
|
|
this.#fetchNotifications();
|
|
}
|
|
this.#render();
|
|
}
|
|
|
|
#categoryIcon(category: string): string {
|
|
switch (category) {
|
|
case "space": return "🏠";
|
|
case "module": return "📦";
|
|
case "system": return "🔐";
|
|
case "social": return "💬";
|
|
default: return "🔔";
|
|
}
|
|
}
|
|
|
|
#timeAgo(iso: string): string {
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const mins = Math.floor(diff / 60_000);
|
|
if (mins < 1) return "just now";
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
const days = Math.floor(hrs / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
#render() {
|
|
const session = getSession();
|
|
if (!session) {
|
|
this.#shadow.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const badge = this.#unreadCount > 0
|
|
? `<span class="badge">${this.#unreadCount > 99 ? "99+" : this.#unreadCount}</span>`
|
|
: "";
|
|
|
|
let panelHTML = "";
|
|
if (this.#open) {
|
|
const header = `
|
|
<div class="panel-header">
|
|
<span class="panel-title">Notifications</span>
|
|
${this.#unreadCount > 0 ? `<button class="mark-all-btn" data-action="mark-all-read">Mark all read</button>` : ""}
|
|
</div>
|
|
`;
|
|
|
|
let body: string;
|
|
if (this.#loading) {
|
|
body = `<div class="panel-empty">Loading...</div>`;
|
|
} else if (this.#notifications.length === 0) {
|
|
body = `<div class="panel-empty">No notifications yet</div>`;
|
|
} else {
|
|
body = this.#notifications.map(n => `
|
|
<div class="notif-item ${n.read ? "read" : "unread"}" data-id="${n.id}">
|
|
<div class="notif-icon">${this.#categoryIcon(n.category)}</div>
|
|
<div class="notif-content">
|
|
<div class="notif-title">${n.title}</div>
|
|
${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
|
|
<div class="notif-meta">
|
|
${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""}
|
|
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
<button class="notif-dismiss" data-dismiss="${n.id}" title="Dismiss">×</button>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
panelHTML = `<div class="panel">${header}${body}</div>`;
|
|
}
|
|
|
|
this.#shadow.innerHTML = `
|
|
<style>${STYLES}</style>
|
|
<div class="bell-wrapper">
|
|
<button class="bell-btn" id="bell-toggle" aria-label="Notifications">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
</svg>
|
|
${badge}
|
|
</button>
|
|
${panelHTML}
|
|
</div>
|
|
`;
|
|
|
|
// ── Event listeners ──
|
|
const toggleBtn = this.#shadow.getElementById("bell-toggle");
|
|
toggleBtn?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#togglePanel();
|
|
});
|
|
|
|
// Close on outside click
|
|
const closeHandler = () => {
|
|
if (this.#open) {
|
|
this.#open = false;
|
|
this.#render();
|
|
}
|
|
};
|
|
document.addEventListener("click", closeHandler, { once: true });
|
|
|
|
// Stop propagation from panel clicks
|
|
this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation());
|
|
|
|
// Mark all read
|
|
this.#shadow.querySelector('[data-action="mark-all-read"]')?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#markAllRead();
|
|
});
|
|
|
|
// Notification item clicks (mark read + navigate)
|
|
this.#shadow.querySelectorAll(".notif-item").forEach((el) => {
|
|
const id = (el as HTMLElement).dataset.id!;
|
|
el.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#markRead(id);
|
|
const n = this.#notifications.find(n => n.id === id);
|
|
if (n?.actionUrl) {
|
|
window.location.href = n.actionUrl;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Dismiss buttons
|
|
this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => {
|
|
const id = (btn as HTMLElement).dataset.dismiss!;
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#dismiss(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
static define(tag = "rstack-notification-bell") {
|
|
if (!customElements.get(tag)) customElements.define(tag, RStackNotificationBell);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// STYLES
|
|
// ============================================================================
|
|
|
|
const STYLES = `
|
|
:host {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.bell-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.bell-btn {
|
|
position: relative;
|
|
background: none;
|
|
border: none;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
cursor: pointer;
|
|
padding: 6px;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: color 0.15s, background 0.15s;
|
|
}
|
|
.bell-btn:hover {
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
|
|
}
|
|
|
|
.badge {
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 2px;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
border-radius: 8px;
|
|
background: #ef4444;
|
|
color: white;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 4px;
|
|
line-height: 1;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.panel {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
margin-top: 8px;
|
|
width: 360px;
|
|
max-height: 480px;
|
|
overflow-y: auto;
|
|
border-radius: 10px;
|
|
background: var(--rs-bg-surface, #1e293b);
|
|
border: 1px solid var(--rs-border, rgba(255,255,255,0.1));
|
|
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
|
|
z-index: 200;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1));
|
|
}
|
|
|
|
.panel-title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
}
|
|
|
|
.mark-all-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--rs-accent, #06b6d4);
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
transition: background 0.15s;
|
|
}
|
|
.mark-all-btn:hover {
|
|
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
|
|
}
|
|
|
|
.panel-empty {
|
|
padding: 32px 16px;
|
|
text-align: center;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.notif-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 10px 16px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.05));
|
|
}
|
|
.notif-item:hover {
|
|
background: var(--rs-bg-hover, rgba(255,255,255,0.06));
|
|
}
|
|
.notif-item.unread {
|
|
background: rgba(6, 182, 212, 0.05);
|
|
}
|
|
.notif-item.unread .notif-title {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.notif-icon {
|
|
flex-shrink: 0;
|
|
font-size: 1rem;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.notif-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.notif-title {
|
|
font-size: 0.8rem;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.notif-body {
|
|
font-size: 0.75rem;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
margin-top: 2px;
|
|
line-height: 1.3;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.notif-meta {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
font-size: 0.7rem;
|
|
color: var(--rs-text-muted, #64748b);
|
|
}
|
|
|
|
.notif-actor {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.notif-dismiss {
|
|
flex-shrink: 0;
|
|
background: none;
|
|
border: none;
|
|
color: var(--rs-text-muted, #64748b);
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
padding: 2px 4px;
|
|
border-radius: 4px;
|
|
opacity: 0;
|
|
transition: opacity 0.15s, color 0.15s;
|
|
}
|
|
.notif-item:hover .notif-dismiss {
|
|
opacity: 1;
|
|
}
|
|
.notif-dismiss:hover {
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
}
|
|
`;
|