rspace-online/shared/components/rstack-notification-bell.ts

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">&times;</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);
}
`;