Compare commits

...

2 Commits

Author SHA1 Message Date
root
a0e705beec feat: redesign notifications settings with per-category in-app/push table
All checks were successful
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 5m49s
Release / Gitea Release (push) Successful in 21s
- Add notify_new_chapters_push field to AppUser, PATCH /api/profile, and profile loader
- Fix bell panel to reload notifications on every open (not just once on mount)
- Replace flat in-app + push toggles with structured category table (Category | In-app | Push)
- Add browser push master subscribe/unsubscribe row above the table
- Push column toggle disabled until browser is subscribed; shows — when unsupported/denied
- Update Notifications row hint to summarise active channels (In-app · Push / Off)
2026-04-12 17:56:53 +05:00
root
761ca83da5 fix: add push_subscriptions collection and notify_new_chapters migration to pb-init-v3.sh 2026-04-12 17:49:23 +05:00
6 changed files with 117 additions and 45 deletions

View File

@@ -335,6 +335,14 @@ create "notifications" '{
{"name":"created", "type":"date"}
]}'
create "push_subscriptions" '{
"name":"push_subscriptions","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"endpoint", "type":"text","required":true},
{"name":"p256dh", "type":"text","required":true},
{"name":"auth", "type":"text","required":true}
]}'
create "ai_jobs" '{
"name":"ai_jobs","type":"base","fields":[
{"name":"kind", "type":"text", "required":true},
@@ -393,6 +401,7 @@ add_field "user_settings" "font_size" "number"
add_field "user_settings" "announce_chapter" "bool"
add_field "user_settings" "audio_mode" "text"
add_field "books" "archived" "bool"
add_field "app_users" "notify_new_chapters" "bool"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \

View File

@@ -96,6 +96,7 @@ export interface User {
polar_customer_id?: string;
polar_subscription_id?: string;
notify_new_chapters?: boolean;
notify_new_chapters_push?: boolean;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -1582,7 +1583,10 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
*/
export async function updateUserNotificationPrefs(
userId: string,
prefs: { notify_new_chapters?: boolean }
prefs: {
notify_new_chapters?: boolean;
notify_new_chapters_push?: boolean;
}
): Promise<void> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {

View File

@@ -64,6 +64,7 @@
} catch (e) { console.error('clear notifications:', e); }
}
$effect(() => { if (data.user) loadNotifications(); });
$effect(() => { if (notificationsOpen && data.user) loadNotifications(); });
const unreadCount = $derived(notifications.filter(n => !n.read).length);
// Close search on navigation

View File

@@ -7,7 +7,7 @@ import { log } from '$lib/server/logger';
* PATCH /api/profile
*
* Update mutable profile preferences (currently: notification preferences).
* Body: { notify_new_chapters?: boolean }
* Body: { notify_new_chapters?: boolean, notify_new_chapters_push?: boolean }
*/
export const PATCH: RequestHandler = async ({ locals, request }) => {
if (!locals.user) error(401, 'Not authenticated');
@@ -19,10 +19,13 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
error(400, 'Invalid JSON');
}
const prefs: { notify_new_chapters?: boolean } = {};
const prefs: { notify_new_chapters?: boolean; notify_new_chapters_push?: boolean } = {};
if (typeof body.notify_new_chapters === 'boolean') {
prefs.notify_new_chapters = body.notify_new_chapters;
}
if (typeof body.notify_new_chapters_push === 'boolean') {
prefs.notify_new_chapters_push = body.notify_new_chapters_push;
}
if (Object.keys(prefs).length === 0) {
error(400, 'No valid preferences provided');

View File

@@ -81,6 +81,7 @@ export const load: PageServerLoad = async ({ locals }) => {
email,
polarCustomerId,
notifyNewChapters: freshUser?.notify_new_chapters ?? true,
notifyNewChaptersPush: freshUser?.notify_new_chapters_push ?? true,
stats: stats ?? {
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
booksPlanToRead: 0, booksDropped: 0, topGenres: [],

View File

@@ -236,8 +236,10 @@
let pushError = $state('');
// ── In-app notifications ──────────────────────────────────────────────────────
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
let notifyNewChaptersSaving = $state(false);
let notifyNewChapters = $state(data.notifyNewChapters ?? true);
let notifyNewChaptersPush = $state(data.notifyNewChaptersPush ?? true);
let notifyNewChaptersSaving = $state(false);
let notifyNewChaptersPushSaving = $state(false);
async function toggleNotifyNewChapters() {
notifyNewChaptersSaving = true;
@@ -248,14 +250,27 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_new_chapters: next })
});
if (res.ok) {
notifyNewChapters = next;
}
if (res.ok) notifyNewChapters = next;
} catch { /* ignore */ } finally {
notifyNewChaptersSaving = false;
}
}
async function toggleNotifyNewChaptersPush() {
notifyNewChaptersPushSaving = true;
const next = !notifyNewChaptersPush;
try {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_new_chapters_push: next })
});
if (res.ok) notifyNewChaptersPush = next;
} catch { /* ignore */ } finally {
notifyNewChaptersPushSaving = false;
}
}
$effect(() => {
if (!browser) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
@@ -802,8 +817,16 @@
</svg>
</span>
<span class="flex-1 text-sm font-medium text-(--color-text)">Notifications</span>
<span class="text-xs mr-2 hidden sm:inline {notifyNewChapters ? 'text-(--color-brand)' : 'text-(--color-muted)'}">
{notifyNewChapters ? 'On' : 'Off'}
<span class="text-xs mr-2 hidden sm:inline text-(--color-muted)">
{#if notifyNewChapters && pushState === 'subscribed'}
<span class="text-(--color-brand)">In-app · Push</span>
{:else if notifyNewChapters}
<span class="text-(--color-brand)">In-app</span>
{:else if pushState === 'subscribed'}
<span class="text-(--color-brand)">Push</span>
{:else}
Off
{/if}
</span>
<svg class={cn(chevronClass, expanded === 'notifications' ? 'rotate-90' : '')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
@@ -814,50 +837,24 @@
<div class="px-5 py-5 space-y-5 bg-(--color-surface-3)/30">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Notifications</span>
<!-- In-app -->
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-(--color-text)">In-app notifications</p>
<p class="text-sm text-(--color-muted) mt-0.5">
{#if notifyNewChapters}
Notified when new chapters arrive in your library.
{:else}
In-app new-chapter notifications are disabled.
{/if}
</p>
</div>
<button
type="button"
onclick={toggleNotifyNewChapters}
disabled={notifyNewChaptersSaving}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50',
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'
)}
role="switch"
aria-checked={notifyNewChapters}
title={notifyNewChapters ? 'Turn off in-app notifications' : 'Turn on in-app notifications'}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Push -->
<!-- Browser push master toggle -->
{#if pushState !== 'unsupported'}
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-(--color-text)">Push notifications</p>
<p class="text-sm text-(--color-muted) mt-0.5">
<p class="text-sm font-medium text-(--color-text)">Browser push</p>
<p class="text-xs text-(--color-muted) mt-0.5">
{#if pushState === 'subscribed'}
Push enabled for new chapters in your library.
This browser is subscribed to push notifications.
{:else if pushState === 'denied'}
Blocked by your browser. Change in browser settings.
Blocked by your browser — change in browser settings.
{:else if pushState === 'loading'}
Updating…
{:else}
Get notified when new chapters arrive.
Subscribe to receive push notifications in this browser.
{/if}
</p>
{#if pushError}
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
<p class="text-xs text-(--color-danger) mt-1">{pushError}</p>
{/if}
</div>
<div class="shrink-0">
@@ -890,6 +887,63 @@
</div>
</div>
{/if}
<!-- Per-category table -->
<div class="rounded-lg border border-(--color-border) overflow-hidden">
<!-- Header -->
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-4 py-2 bg-(--color-surface-3)/60 border-b border-(--color-border)">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Category</span>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-12 text-center">In-app</span>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-12 text-center">Push</span>
</div>
<!-- New chapters row -->
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-4 py-3">
<div>
<p class="text-sm font-medium text-(--color-text)">New chapters</p>
<p class="text-xs text-(--color-muted) mt-0.5">When a book in your library gets new chapters</p>
</div>
<!-- In-app toggle -->
<div class="w-12 flex justify-center">
<button
type="button"
role="switch"
aria-checked={notifyNewChapters}
aria-label="In-app notifications for new chapters"
onclick={toggleNotifyNewChapters}
disabled={notifyNewChaptersSaving}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) disabled:opacity-50',
notifyNewChapters ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChapters ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Push toggle -->
<div class="w-12 flex justify-center">
{#if pushState === 'unsupported'}
<span class="text-xs text-(--color-muted)" title="Push not supported in this browser"></span>
{:else if pushState === 'denied'}
<span class="text-xs text-(--color-muted)" title="Push blocked by browser"></span>
{:else}
<button
type="button"
role="switch"
aria-checked={notifyNewChaptersPush && pushState === 'subscribed'}
aria-label="Push notifications for new chapters"
onclick={toggleNotifyNewChaptersPush}
disabled={notifyNewChaptersPushSaving || pushState !== 'subscribed'}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) disabled:opacity-40',
notifyNewChaptersPush && pushState === 'subscribed' ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', notifyNewChaptersPush && pushState === 'subscribed' ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
{/if}
</div>
</div>
</div>
</div>
{/if}