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:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />
|
||||
|
||||
@@ -179,17 +179,16 @@
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
<a
|
||||
href="/books/{heroBook.book.slug}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
||||
title="Listen to narration"
|
||||
title="Book info"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
Info
|
||||
</a>
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
|
||||
63
ui/src/routes/api/push-subscription/+server.ts
Normal file
63
ui/src/routes/api/push-subscription/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* POST /api/push-subscription
|
||||
* Registers a browser push subscription for the current user.
|
||||
* Body: { endpoint, keys: { p256dh, auth } }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Login required');
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.endpoint || !body?.keys?.p256dh || !body?.keys?.auth) {
|
||||
throw error(400, 'Invalid push subscription object');
|
||||
}
|
||||
|
||||
const res = await backendFetch('/api/push-subscriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: locals.user.id,
|
||||
endpoint: body.endpoint,
|
||||
p256dh: body.keys.p256dh,
|
||||
auth: body.keys.auth,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => 'backend error');
|
||||
throw error(res.status, msg);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/push-subscription
|
||||
* Unregisters a push subscription by endpoint.
|
||||
* Body: { endpoint }
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Login required');
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.endpoint) throw error(400, 'endpoint required');
|
||||
|
||||
const res = await backendFetch('/api/push-subscriptions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: locals.user.id,
|
||||
endpoint: body.endpoint,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => 'backend error');
|
||||
throw error(res.status, msg);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -233,6 +233,96 @@
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
// ── Push notifications ────────────────────────────────────────────────────────
|
||||
type PushState = 'unsupported' | 'default' | 'subscribed' | 'denied' | 'loading';
|
||||
let pushState = $state<PushState>('unsupported');
|
||||
let pushError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
pushState = 'unsupported';
|
||||
return;
|
||||
}
|
||||
// Check current permission / subscription state
|
||||
(async () => {
|
||||
const perm = Notification.permission;
|
||||
if (perm === 'denied') { pushState = 'denied'; return; }
|
||||
const reg = await navigator.serviceWorker.getRegistration('/');
|
||||
if (!reg) { pushState = 'default'; return; }
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
pushState = sub ? 'subscribed' : 'default';
|
||||
})();
|
||||
});
|
||||
|
||||
async function subscribePush() {
|
||||
pushError = '';
|
||||
pushState = 'loading';
|
||||
try {
|
||||
// Fetch VAPID public key from backend
|
||||
const keyRes = await fetch('/api/push-subscriptions/vapid-public-key');
|
||||
if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; }
|
||||
const { public_key } = await keyRes.json() as { public_key: string };
|
||||
|
||||
// Register / get existing service worker
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
// Subscribe with VAPID
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||
});
|
||||
|
||||
const json = sub.toJSON() as {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
};
|
||||
|
||||
const saveRes = await fetch('/api/push-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(json),
|
||||
});
|
||||
if (!saveRes.ok) { pushError = 'Failed to save subscription. Please try again.'; pushState = 'default'; return; }
|
||||
|
||||
pushState = 'subscribed';
|
||||
} catch (e) {
|
||||
pushError = e instanceof Error ? e.message : 'Failed to enable notifications.';
|
||||
pushState = Notification.permission === 'denied' ? 'denied' : 'default';
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribePush() {
|
||||
pushError = '';
|
||||
pushState = 'loading';
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration('/');
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await fetch('/api/push-subscription', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
pushState = 'default';
|
||||
} catch (e) {
|
||||
pushError = e instanceof Error ? e.message : 'Failed to disable notifications.';
|
||||
pushState = 'subscribed';
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const arr = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
||||
return arr.buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
function parseUA(ua: string): string {
|
||||
if (!ua) return 'Unknown browser';
|
||||
if (/Mobile/i.test(ua)) {
|
||||
@@ -634,6 +724,64 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ── Push notifications ────────────────────────────────────────────────── -->
|
||||
{#if pushState !== 'unsupported'}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Push notifications</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
{#if pushState === 'subscribed'}
|
||||
You'll receive a push notification when new chapters are added to books in your library.
|
||||
{:else if pushState === 'denied'}
|
||||
Notifications are blocked by your browser. Change the permission in your browser settings.
|
||||
{:else}
|
||||
Get notified when new chapters arrive for books in your library.
|
||||
{/if}
|
||||
</p>
|
||||
{#if pushError}
|
||||
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
{#if pushState === 'subscribed'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={unsubscribePush}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
Turn off
|
||||
</button>
|
||||
{:else if pushState === 'denied'}
|
||||
<span class="text-xs text-(--color-muted) italic">Blocked</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={subscribePush}
|
||||
disabled={pushState === 'loading'}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) disabled:opacity-60 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{#if pushState === 'loading'}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
{/if}
|
||||
Turn on
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
|
||||
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
|
||||
<button
|
||||
|
||||
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