feat: web push notifications for new chapters
All checks were successful
Release / Test backend (push) Successful in 4m12s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 5m46s
Release / Gitea Release (push) Successful in 35s

- 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:
root
2026-04-11 14:59:21 +05:00
parent 3a9f3b773e
commit b95c811898
17 changed files with 779 additions and 7 deletions

View File

@@ -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}

View 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 });
};

View File

@@ -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