feat: add in-app notifications system with bell icon, polling, and notifications page

This commit is contained in:
2026-02-07 14:18:00 +01:00
parent 1bd44e6632
commit f6ba0085f9
11 changed files with 585 additions and 0 deletions

View File

@@ -761,6 +761,44 @@ class DirectusService {
return `${this.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}`
}
// ── Notifications ──
async getNotifications(userHash, options = {}) {
const params = {
fields: ['id', 'type', 'reference_id', 'read', 'date_created', 'user_hash'],
filter: { user_hash: { _eq: userHash } },
sort: ['-date_created'],
limit: options.limit || 50
}
if (options.unreadOnly) {
params.filter.read = { _eq: false }
}
const response = await this.get('/items/notifications', params)
return response.data
}
async getUnreadCount(userHash) {
const response = await this.get('/items/notifications', {
filter: { user_hash: { _eq: userHash }, read: { _eq: false } },
aggregate: { count: 'id' }
})
return parseInt(response.data?.[0]?.count?.id || '0', 10)
}
async markNotificationRead(id) {
return this.patch(`/items/notifications/${id}`, { read: true })
}
async markAllNotificationsRead(userHash) {
const unread = await this.getNotifications(userHash, { unreadOnly: true })
const updates = unread.map(n => this.markNotificationRead(n.id))
return Promise.all(updates)
}
async deleteNotification(id) {
return this.delete(`/items/notifications/${id}`)
}
getThumbnailUrl(fileId, size = 300) {
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' })
}

View File

@@ -0,0 +1,115 @@
/**
* Notifications Service - Polls and manages user notifications
*/
import { directus } from './directus.js'
import { auth } from './auth.js'
class NotificationsService {
constructor() {
this.unreadCount = 0
this.notifications = []
this.listeners = new Set()
this.pollTimer = null
this.pollInterval = 60 * 1000 // 60 seconds
this.userHash = null
}
async init() {
if (!auth.isLoggedIn()) return
this.userHash = await this._getUserHash()
if (!this.userHash) return
await this.refresh()
this.startPolling()
}
async _getUserHash() {
const user = await auth.getUser()
return user?.id || null
}
async refresh() {
if (!this.userHash) return
try {
const [count, notifications] = await Promise.all([
directus.getUnreadCount(this.userHash),
directus.getNotifications(this.userHash)
])
this.unreadCount = count
this.notifications = notifications
this.notify()
} catch (e) {
console.error('Failed to fetch notifications:', e)
}
}
startPolling() {
this.stopPolling()
this.pollTimer = setInterval(() => this.refresh(), this.pollInterval)
document.addEventListener('visibilitychange', this._onVisibility)
}
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
document.removeEventListener('visibilitychange', this._onVisibility)
}
_onVisibility = () => {
if (document.visibilityState === 'visible') {
this.refresh()
}
}
async markRead(id) {
await directus.markNotificationRead(id)
const n = this.notifications.find(n => n.id === id)
if (n) {
n.read = true
this.unreadCount = Math.max(0, this.unreadCount - 1)
this.notify()
}
}
async markAllRead() {
if (!this.userHash) return
await directus.markAllNotificationsRead(this.userHash)
this.notifications.forEach(n => n.read = true)
this.unreadCount = 0
this.notify()
}
async remove(id) {
await directus.deleteNotification(id)
this.notifications = this.notifications.filter(n => n.id !== id)
this.unreadCount = this.notifications.filter(n => !n.read).length
this.notify()
}
subscribe(callback) {
this.listeners.add(callback)
return () => this.listeners.delete(callback)
}
notify() {
for (const cb of this.listeners) {
cb(this.unreadCount, this.notifications)
}
}
destroy() {
this.stopPolling()
this.listeners.clear()
this.notifications = []
this.unreadCount = 0
this.userHash = null
}
}
export const notificationsService = new NotificationsService()