feat: web push notifications for new chapters
- Service worker (src/service-worker.ts) handles push events and notification clicks, navigating to the book page on tap - Web app manifest (manifest.webmanifest) linked in app.html - Profile page: push notification toggle (subscribe/unsubscribe) using the browser Notification + PushManager API with VAPID - API route POST/DELETE /api/push-subscription proxies to backend - Go backend: push_subscriptions PocketBase collection storage methods (SavePushSubscription, DeletePushSubscription, ListPushSubscriptionsByBook) in storage/store.go - handlers_push.go: GET vapid-public-key, POST/DELETE subscription - webpush package: VAPID-signed sends via webpush-go, SendToBook fans out to all users who have the book in their library - Runner fires push to subscribers whenever ChaptersScraped > 0 after a successful book scrape - Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars - domain.ScrapeResult gets a Slug field; orchestrator populates it
This commit is contained in:
73
ui/src/service-worker.ts
Normal file
73
ui/src/service-worker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope;
|
||||
|
||||
// ── Install / Activate ────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// ── Push notifications ────────────────────────────────────────────────────────
|
||||
|
||||
interface PushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
let payload: PushPayload;
|
||||
try {
|
||||
payload = event.data.json() as PushPayload;
|
||||
} catch {
|
||||
payload = { title: 'LibNovel', body: event.data.text() };
|
||||
}
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: payload.body,
|
||||
icon: payload.icon ?? '/icon-192.png',
|
||||
badge: payload.badge ?? '/favicon-32.png',
|
||||
data: { url: payload.url ?? '/' },
|
||||
// Show notification even when the app is focused
|
||||
requireInteraction: false,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
// ── Notification click ────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url: string = (event.notification.data as { url?: string })?.url ?? '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Focus existing window if it has the target URL already open
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise open a new window
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user