diff --git a/backend/internal/runner/runner.go b/backend/internal/runner/runner.go
index 620fc6e..f279cba 100644
--- a/backend/internal/runner/runner.go
+++ b/backend/internal/runner/runner.go
@@ -539,7 +539,21 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
"/admin/tasks")
}
- // Send push notifications to users who have this book in their library.
+ // Fan-out in-app new-chapter notification to all users who have this book
+ // in their library. Runs in background so it doesn't block the task loop.
+ if r.deps.Store != nil && result.ChaptersScraped > 0 &&
+ result.Slug != "" && task.Kind != "catalogue" {
+ go func() {
+ notifyCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ title := result.Slug
+ _ = r.deps.Store.NotifyUsersWithBook(notifyCtx, result.Slug,
+ "New chapters available",
+ fmt.Sprintf("%d new chapter(s) added to %s", result.ChaptersScraped, title),
+ "/books/"+result.Slug)
+ }()
+ }
+ // Send Web Push notifications to subscribed browsers.
if r.deps.WebPush != nil && r.deps.Store != nil &&
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go
index 5e5c5d4..8e262ab 100644
--- a/backend/internal/storage/store.go
+++ b/backend/internal/storage/store.go
@@ -1639,3 +1639,56 @@ func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([
}
return subs, nil
}
+
+// NotifyUsersWithBook creates an in-app notification for every logged-in user
+// who has slug in their library. Errors for individual users are logged but
+// do not abort the loop. Returns the number of notifications created.
+func (s *Store) NotifyUsersWithBook(ctx context.Context, slug, title, message, link string) int {
+ userIDs, err := s.ListUserIDsWithBook(ctx, slug)
+ if err != nil || len(userIDs) == 0 {
+ return 0
+ }
+ var n int
+ for _, uid := range userIDs {
+ if createErr := s.CreateNotification(ctx, uid, title, message, link); createErr == nil {
+ n++
+ }
+ }
+ return n
+}
+// who have slug in their user_library. Used to fan-out new-chapter notifications.
+// Admin users and users who have opted out of in-app new-chapter notifications
+// (notify_new_chapters=false on app_users) are excluded.
+func (s *Store) ListUserIDsWithBook(ctx context.Context, slug string) ([]string, error) {
+ // Collect user IDs to skip: admins + opted-out users.
+ skipIDs := make(map[string]bool)
+ excludedItems, err := s.pb.listAll(ctx, "app_users", `role="admin"||notify_new_chapters=false`, "")
+ if err == nil {
+ for _, raw := range excludedItems {
+ var rec struct {
+ ID string `json:"id"`
+ }
+ if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
+ skipIDs[rec.ID] = true
+ }
+ }
+ }
+
+ filter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
+ items, err := s.pb.listAll(ctx, "user_library", filter, "")
+ if err != nil {
+ return nil, fmt.Errorf("ListUserIDsWithBook: %w", err)
+ }
+ seen := make(map[string]bool)
+ var ids []string
+ for _, raw := range items {
+ var rec struct {
+ UserID string `json:"user_id"`
+ }
+ if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] && !skipIDs[rec.UserID] {
+ seen[rec.UserID] = true
+ ids = append(ids, rec.UserID)
+ }
+ }
+ return ids, nil
+}
diff --git a/ui/src/lib/components/NotificationsModal.svelte b/ui/src/lib/components/NotificationsModal.svelte
new file mode 100644
index 0000000..c6a92e1
--- /dev/null
+++ b/ui/src/lib/components/NotificationsModal.svelte
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
{ if (e.target === e.currentTarget) onclose(); }}
+>
+
+
+
e.stopPropagation()}
+ >
+
+
+
+ Notifications
+ {#if unreadCount > 0}
+
+ {unreadCount}
+
+ {/if}
+
+
+
+ {#if unreadCount > 0}
+ Mark all read
+ {/if}
+ {#if notifications.length > 0}
+ Clear all
+ {/if}
+
+ Cancel
+
+
+
+
+
+
+ filter = 'all'}
+ class={cn(
+ 'text-xs px-3 py-1.5 rounded-l border border-(--color-border) transition-colors',
+ filter === 'all'
+ ? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
+ : 'text-(--color-muted) hover:text-(--color-text)'
+ )}
+ >All ({notifications.length})
+ filter = 'unread'}
+ class={cn(
+ 'text-xs px-3 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors',
+ filter === 'unread'
+ ? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
+ : 'text-(--color-muted) hover:text-(--color-text)'
+ )}
+ >Unread ({unreadCount})
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts
index 9b9230d..e025a4f 100644
--- a/ui/src/lib/server/pocketbase.ts
+++ b/ui/src/lib/server/pocketbase.ts
@@ -95,6 +95,7 @@ export interface User {
oauth_id?: string;
polar_customer_id?: string;
polar_subscription_id?: string;
+ notify_new_chapters?: boolean;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -1481,6 +1482,25 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
}
}
+/**
+ * Update a user's notification preferences (stored on app_users record).
+ */
+export async function updateUserNotificationPrefs(
+ userId: string,
+ prefs: { notify_new_chapters?: boolean }
+): Promise {
+ const token = await getToken();
+ const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
+ method: 'PATCH',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify(prefs)
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => '');
+ throw new Error(`updateUserNotificationPrefs failed: ${res.status} ${body}`);
+ }
+}
+
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface PBBookComment {
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
index d886f79..cbd0136 100644
--- a/ui/src/routes/+layout.svelte
+++ b/ui/src/routes/+layout.svelte
@@ -13,6 +13,7 @@
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
import SearchModal from '$lib/components/SearchModal.svelte';
+ import NotificationsModal from '$lib/components/NotificationsModal.svelte';
import { fly, fade } from 'svelte/transition';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -26,7 +27,6 @@
// Notifications
let notificationsOpen = $state(false);
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
- let notifFilter = $state<'all' | 'unread'>('all');
async function loadNotifications() {
if (!data.user) return;
try {
@@ -65,9 +65,6 @@
}
$effect(() => { if (data.user) loadNotifications(); });
const unreadCount = $derived(notifications.filter(n => !n.read).length);
- const filteredNotifications = $derived(
- notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications
- );
// Close search on navigation
$effect(() => {
@@ -588,12 +585,12 @@
{/if}
-
- {#if data.user?.role === 'admin'}
-
+
+ {#if data.user}
+
{ notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
+ onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
title="Notifications"
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
>
@@ -604,87 +601,6 @@
{/if}
- {#if notificationsOpen}
-
-
-
-
Notifications
-
- {#if unreadCount > 0}
- Mark all read
- {/if}
- {#if notifications.length > 0}
- Clear all
- {/if}
-
-
-
-
- notifFilter = 'all'}
- class="text-xs px-2.5 py-1 rounded-l border border-(--color-border) transition-colors {notifFilter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
- >All ({notifications.length})
- notifFilter = 'unread'}
- class="text-xs px-2.5 py-1 rounded-r border border-l-0 border-(--color-border) transition-colors {notifFilter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
- >Unread ({unreadCount})
-
-
-
- {#if filteredNotifications.length === 0}
-
- {notifFilter === 'unread' ? 'No unread notifications' : 'No notifications'}
-
- {:else}
- {#each filteredNotifications as n (n.id)}
-
- {/each}
- {/if}
-
-
-
-
- {/if}
{/if}
@@ -1222,6 +1138,20 @@
{ searchOpen = false; }} />
{/if}
+
+{#if notificationsOpen && data.user}
+ { notificationsOpen = false; }}
+ onMarkRead={markRead}
+ onMarkAllRead={markAllRead}
+ onDismiss={dismissNotification}
+ onClearAll={clearAllNotifications}
+ />
+{/if}
+
{
// Don't intercept when typing in an input/textarea
const tag = (e.target as HTMLElement).tagName;
diff --git a/ui/src/routes/api/profile/+server.ts b/ui/src/routes/api/profile/+server.ts
index 091ec05..f7f186f 100644
--- a/ui/src/routes/api/profile/+server.ts
+++ b/ui/src/routes/api/profile/+server.ts
@@ -1,8 +1,43 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
-import { deleteUserAccount } from '$lib/server/pocketbase';
+import { deleteUserAccount, updateUserNotificationPrefs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
+/**
+ * PATCH /api/profile
+ *
+ * Update mutable profile preferences (currently: notification preferences).
+ * Body: { notify_new_chapters?: boolean }
+ */
+export const PATCH: RequestHandler = async ({ locals, request }) => {
+ if (!locals.user) error(401, 'Not authenticated');
+
+ let body: Record;
+ try {
+ body = await request.json();
+ } catch {
+ error(400, 'Invalid JSON');
+ }
+
+ const prefs: { notify_new_chapters?: boolean } = {};
+ if (typeof body.notify_new_chapters === 'boolean') {
+ prefs.notify_new_chapters = body.notify_new_chapters;
+ }
+
+ if (Object.keys(prefs).length === 0) {
+ error(400, 'No valid preferences provided');
+ }
+
+ try {
+ await updateUserNotificationPrefs(locals.user.id, prefs);
+ } catch (e) {
+ log.error('profile', 'PATCH /api/profile failed', { userId: locals.user.id, err: String(e) });
+ error(500, { message: 'Failed to update preferences. Please try again.' });
+ }
+
+ return json({ ok: true });
+};
+
/**
* DELETE /api/profile
*
diff --git a/ui/src/routes/notifications/+page.server.ts b/ui/src/routes/notifications/+page.server.ts
new file mode 100644
index 0000000..065d231
--- /dev/null
+++ b/ui/src/routes/notifications/+page.server.ts
@@ -0,0 +1,28 @@
+import type { PageServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { backendFetch } from '$lib/server/scraper';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ // Admins have their own full notifications page
+ if (locals.user?.role === 'admin') {
+ redirect(302, '/admin/notifications');
+ }
+
+ const userId = locals.user!.id;
+ try {
+ const res = await backendFetch('/api/notifications?user_id=' + userId);
+ const data = await res.json().catch(() => ({ notifications: [] }));
+ return {
+ userId,
+ notifications: (data.notifications ?? []) as Array<{
+ id: string;
+ title: string;
+ message: string;
+ link: string;
+ read: boolean;
+ }>
+ };
+ } catch {
+ return { userId, notifications: [] };
+ }
+};
diff --git a/ui/src/routes/notifications/+page.svelte b/ui/src/routes/notifications/+page.svelte
new file mode 100644
index 0000000..0e05a15
--- /dev/null
+++ b/ui/src/routes/notifications/+page.svelte
@@ -0,0 +1,127 @@
+
+
+
+ Notifications
+
+
+
+
+
+
Notifications
+ {#if unreadCount > 0}
+
{unreadCount} unread
+ {/if}
+
+
+ {#if unreadCount > 0}
+ Mark all read
+ {/if}
+ {#if notifications.length > 0}
+ Clear all
+ {/if}
+
+
+
+
+
+ filter = 'all'}
+ class="text-sm px-4 py-1.5 rounded-l border border-(--color-border) transition-colors {filter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
+ >All ({notifications.length})
+ filter = 'unread'}
+ class="text-sm px-4 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors {filter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
+ >Unread ({unreadCount})
+
+
+
+ {#if filtered.length === 0}
+
+ {filter === 'unread' ? 'No unread notifications' : 'No notifications'}
+
+ {:else}
+
+ {#each filtered as n (n.id)}
+
+ {/each}
+
+ {/if}
+
diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts
index a508a80..dfef050 100644
--- a/ui/src/routes/profile/+page.server.ts
+++ b/ui/src/routes/profile/+page.server.ts
@@ -5,7 +5,8 @@ import {
getUserByUsername,
getUserStats,
allProgress,
- getBooksBySlugs
+ getBooksBySlugs,
+ getUserById
} from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -41,12 +42,18 @@ export const load: PageServerLoad = async ({ locals }) => {
};
}
+ // Helper: fetch fresh user record (for notification prefs not in auth token)
+ async function fetchFreshUser() {
+ return getUserById(locals.user!.id);
+ }
+
// Run all three independent groups concurrently
- const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
+ const [userRecord, sessionsResult, statsResult, historyResult, freshUserResult] = await Promise.allSettled([
fetchUserRecord(),
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id),
- fetchHistory()
+ fetchHistory(),
+ fetchFreshUser()
]);
if (userRecord.status === 'rejected')
@@ -57,7 +64,6 @@ export const load: PageServerLoad = async ({ locals }) => {
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
if (historyResult.status === 'rejected')
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
-
const { avatarUrl = null, email = null, polarCustomerId = null } =
userRecord.status === 'fulfilled' ? userRecord.value : {};
const sessions =
@@ -66,12 +72,15 @@ export const load: PageServerLoad = async ({ locals }) => {
statsResult.status === 'fulfilled' ? statsResult.value : null;
const history =
historyResult.status === 'fulfilled' ? historyResult.value : [];
+ const freshUser =
+ freshUserResult.status === 'fulfilled' ? freshUserResult.value : null;
return {
user: locals.user,
avatarUrl,
email,
polarCustomerId,
+ notifyNewChapters: freshUser?.notify_new_chapters ?? true,
stats: stats ?? {
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte
index d2d54bc..e797cc2 100644
--- a/ui/src/routes/profile/+page.svelte
+++ b/ui/src/routes/profile/+page.svelte
@@ -238,6 +238,27 @@
let pushState = $state('unsupported');
let pushError = $state('');
+ // ── In-app notifications ──────────────────────────────────────────────────────
+ let notifyNewChapters = $state(data.notifyNewChapters ?? true);
+ let notifyNewChaptersSaving = $state(false);
+
+ async function toggleNotifyNewChapters() {
+ notifyNewChaptersSaving = true;
+ const next = !notifyNewChapters;
+ try {
+ const res = await fetch('/api/profile', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ notify_new_chapters: next })
+ });
+ if (res.ok) {
+ notifyNewChapters = next;
+ }
+ } catch { /* ignore */ } finally {
+ notifyNewChaptersSaving = false;
+ }
+ }
+
$effect(() => {
if (!browser) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
@@ -724,6 +745,39 @@
{/if}
+
+
+
+
+
In-app notifications
+
+ {#if notifyNewChapters}
+ You'll receive a notification when new chapters are added to books in your library.
+ {:else}
+ In-app new-chapter notifications are disabled.
+ {/if}
+
+
+
+
+
+
+
+
{#if pushState !== 'unsupported'}