feat: notifications modal, admin dedup, and in-app notification preferences
- Replace bell dropdown with full-screen NotificationsModal (mirrors SearchModal pattern) - Notifications visible to all logged-in users (not just admin) - Admin users excluded from new-chapter fan-out (dedup vs Scrape Complete notification) - Users with notify_new_chapters=false opted out of new-chapter in-app notifications - Toggle in profile page to enable/disable in-app new-chapter notifications - PATCH /api/profile endpoint to save notification preferences - User-facing /notifications page (admin redirects to /admin/notifications)
This commit is contained in:
@@ -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),
|
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
|
||||||
"/admin/tasks")
|
"/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 &&
|
if r.deps.WebPush != nil && r.deps.Store != nil &&
|
||||||
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
|
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
|
||||||
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
|
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
|
||||||
|
|||||||
@@ -1639,3 +1639,56 @@ func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([
|
|||||||
}
|
}
|
||||||
return subs, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
184
ui/src/lib/components/NotificationsModal.svelte
Normal file
184
ui/src/lib/components/NotificationsModal.svelte
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
notifications: Notification[];
|
||||||
|
userId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onclose: () => void;
|
||||||
|
onMarkRead: (id: string) => void;
|
||||||
|
onMarkAllRead: () => void;
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
notifications,
|
||||||
|
userId,
|
||||||
|
isAdmin,
|
||||||
|
onclose,
|
||||||
|
onMarkRead,
|
||||||
|
onMarkAllRead,
|
||||||
|
onDismiss,
|
||||||
|
onClearAll,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let filter = $state<'all' | 'unread'>('all');
|
||||||
|
|
||||||
|
const filtered = $derived(
|
||||||
|
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||||
|
);
|
||||||
|
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||||
|
|
||||||
|
// Body scroll lock + Escape to close
|
||||||
|
$effect(() => {
|
||||||
|
if (browser) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewAllHref = $derived(isAdmin ? '/admin/notifications' : '/notifications');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[70] flex flex-col"
|
||||||
|
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||||
|
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||||
|
>
|
||||||
|
<!-- Modal panel — slides down from top -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
|
||||||
|
style="max-height: 100svh;"
|
||||||
|
onpointerdown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-base font-semibold text-(--color-text)">Notifications</span>
|
||||||
|
{#if unreadCount > 0}
|
||||||
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if unreadCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onMarkAllRead}
|
||||||
|
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
|
||||||
|
>Mark all read</button>
|
||||||
|
{/if}
|
||||||
|
{#if notifications.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClearAll}
|
||||||
|
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
|
||||||
|
>Clear all</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onclose}
|
||||||
|
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||||
|
aria-label="Close notifications"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter tabs -->
|
||||||
|
<div class="flex gap-0 px-4 py-2 border-b border-(--color-border)/60 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => 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})</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => 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})</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable list -->
|
||||||
|
<div class="flex-1 overflow-y-auto overscroll-contain min-h-0">
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<div class="py-16 text-center text-(--color-muted) text-sm">
|
||||||
|
{filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each filtered as n (n.id)}
|
||||||
|
<div class={cn(
|
||||||
|
'flex items-start gap-1 border-b border-(--color-border)/40 last:border-0 hover:bg-(--color-surface-2) group transition-colors',
|
||||||
|
n.read && 'opacity-60'
|
||||||
|
)}>
|
||||||
|
<a
|
||||||
|
href={n.link || (isAdmin ? '/admin' : '/')}
|
||||||
|
onclick={() => { onMarkRead(n.id); onclose(); }}
|
||||||
|
class="flex-1 px-4 py-3.5 min-w-0"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
{#if !n.read}
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm font-semibold text-(--color-text) truncate">{n.title}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</p>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onDismiss(n.id)}
|
||||||
|
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-4 py-3 border-t border-(--color-border)/40 shrink-0">
|
||||||
|
<a
|
||||||
|
href={viewAllHref}
|
||||||
|
onclick={onclose}
|
||||||
|
class="block text-center text-sm text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
||||||
|
>View all notifications</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -95,6 +95,7 @@ export interface User {
|
|||||||
oauth_id?: string;
|
oauth_id?: string;
|
||||||
polar_customer_id?: string;
|
polar_customer_id?: string;
|
||||||
polar_subscription_id?: string;
|
polar_subscription_id?: string;
|
||||||
|
notify_new_chapters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
// ─── 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<void> {
|
||||||
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PBBookComment {
|
export interface PBBookComment {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||||
|
import NotificationsModal from '$lib/components/NotificationsModal.svelte';
|
||||||
import { fly, fade } from 'svelte/transition';
|
import { fly, fade } from 'svelte/transition';
|
||||||
|
|
||||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||||
@@ -26,7 +27,6 @@
|
|||||||
// Notifications
|
// Notifications
|
||||||
let notificationsOpen = $state(false);
|
let notificationsOpen = $state(false);
|
||||||
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
|
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
|
||||||
let notifFilter = $state<'all' | 'unread'>('all');
|
|
||||||
async function loadNotifications() {
|
async function loadNotifications() {
|
||||||
if (!data.user) return;
|
if (!data.user) return;
|
||||||
try {
|
try {
|
||||||
@@ -65,9 +65,6 @@
|
|||||||
}
|
}
|
||||||
$effect(() => { if (data.user) loadNotifications(); });
|
$effect(() => { if (data.user) loadNotifications(); });
|
||||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||||
const filteredNotifications = $derived(
|
|
||||||
notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
|
||||||
);
|
|
||||||
|
|
||||||
// Close search on navigation
|
// Close search on navigation
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -588,12 +585,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Notifications bell -->
|
<!-- Notifications bell -->
|
||||||
{#if data.user?.role === 'admin'}
|
{#if data.user}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => { 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"
|
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"
|
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 @@
|
|||||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if notificationsOpen}
|
|
||||||
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 flex flex-col max-h-[28rem]">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between px-3 pt-3 pb-2 shrink-0">
|
|
||||||
<span class="text-sm font-semibold">Notifications</span>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{#if unreadCount > 0}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={markAllRead}
|
|
||||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
|
||||||
>Mark all read</button>
|
|
||||||
{/if}
|
|
||||||
{#if notifications.length > 0}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={clearAllNotifications}
|
|
||||||
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
|
||||||
>Clear all</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Filter tabs -->
|
|
||||||
<div class="flex gap-0 px-3 pb-2 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => 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})</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => 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})</button>
|
|
||||||
</div>
|
|
||||||
<!-- List -->
|
|
||||||
<div class="overflow-y-auto flex-1 min-h-0">
|
|
||||||
{#if filteredNotifications.length === 0}
|
|
||||||
<div class="p-4 text-center text-(--color-muted) text-sm">
|
|
||||||
{notifFilter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each filteredNotifications as n (n.id)}
|
|
||||||
<div class="flex items-start gap-1 border-b border-(--color-border)/40 hover:bg-(--color-surface-3) group {n.read ? 'opacity-60' : ''}">
|
|
||||||
<a
|
|
||||||
href={n.link || '/admin'}
|
|
||||||
onclick={() => { markRead(n.id); notificationsOpen = false; }}
|
|
||||||
class="flex-1 p-3 min-w-0"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
{#if !n.read}
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
|
|
||||||
{/if}
|
|
||||||
<span class="text-sm font-medium truncate">{n.title}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</div>
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => dismissNotification(n.id)}
|
|
||||||
class="shrink-0 p-2.5 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
title="Dismiss"
|
|
||||||
>
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="px-3 py-2 border-t border-(--color-border)/40 shrink-0">
|
|
||||||
<a
|
|
||||||
href="/admin/notifications"
|
|
||||||
onclick={() => notificationsOpen = false}
|
|
||||||
class="block text-center text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
|
||||||
>View all notifications</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Theme dropdown (desktop) -->
|
<!-- Theme dropdown (desktop) -->
|
||||||
@@ -1222,6 +1138,20 @@
|
|||||||
<SearchModal onclose={() => { searchOpen = false; }} />
|
<SearchModal onclose={() => { searchOpen = false; }} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Notifications modal — full-screen, shown for all logged-in users -->
|
||||||
|
{#if notificationsOpen && data.user}
|
||||||
|
<NotificationsModal
|
||||||
|
notifications={notifications}
|
||||||
|
userId={data.user.id}
|
||||||
|
isAdmin={data.user.role === 'admin'}
|
||||||
|
onclose={() => { notificationsOpen = false; }}
|
||||||
|
onMarkRead={markRead}
|
||||||
|
onMarkAllRead={markAllRead}
|
||||||
|
onDismiss={dismissNotification}
|
||||||
|
onClearAll={clearAllNotifications}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<svelte:window onkeydown={(e) => {
|
<svelte:window onkeydown={(e) => {
|
||||||
// Don't intercept when typing in an input/textarea
|
// Don't intercept when typing in an input/textarea
|
||||||
const tag = (e.target as HTMLElement).tagName;
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
|||||||
@@ -1,8 +1,43 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { deleteUserAccount } from '$lib/server/pocketbase';
|
import { deleteUserAccount, updateUserNotificationPrefs } from '$lib/server/pocketbase';
|
||||||
import { log } from '$lib/server/logger';
|
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<string, unknown>;
|
||||||
|
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
|
* DELETE /api/profile
|
||||||
*
|
*
|
||||||
|
|||||||
28
ui/src/routes/notifications/+page.server.ts
Normal file
28
ui/src/routes/notifications/+page.server.ts
Normal file
@@ -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: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
127
ui/src/routes/notifications/+page.svelte
Normal file
127
ui/src/routes/notifications/+page.svelte
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
type Notification = { id: string; title: string; message: string; link: string; read: boolean };
|
||||||
|
|
||||||
|
let notifications = $state<Notification[]>(data.notifications);
|
||||||
|
let filter = $state<'all' | 'unread'>('all');
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const filtered = $derived(
|
||||||
|
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||||
|
);
|
||||||
|
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||||
|
|
||||||
|
async function markRead(id: string) {
|
||||||
|
await fetch('/api/notifications/' + id, { method: 'PATCH' }).catch(() => {});
|
||||||
|
notifications = notifications.map(n => n.id === id ? { ...n, read: true } : n);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismiss(id: string) {
|
||||||
|
await fetch('/api/notifications/' + id, { method: 'DELETE' }).catch(() => {});
|
||||||
|
notifications = notifications.filter(n => n.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications?user_id=' + data.userId, { method: 'PATCH' });
|
||||||
|
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
if (!confirm('Clear all notifications?')) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications?user_id=' + data.userId, { method: 'DELETE' });
|
||||||
|
notifications = [];
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Notifications</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">Notifications</h1>
|
||||||
|
{#if unreadCount > 0}
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5">{unreadCount} unread</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if unreadCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={markAllRead}
|
||||||
|
disabled={busy}
|
||||||
|
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||||
|
>Mark all read</button>
|
||||||
|
{/if}
|
||||||
|
{#if notifications.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearAll}
|
||||||
|
disabled={busy}
|
||||||
|
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-red-400 hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||||
|
>Clear all</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter tabs -->
|
||||||
|
<div class="flex gap-0 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => 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})</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => 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})</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<div class="py-16 text-center text-(--color-muted)">
|
||||||
|
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||||
|
{#each filtered as n (n.id)}
|
||||||
|
<div class="flex items-start gap-2 border-b border-(--color-border)/40 last:border-b-0 hover:bg-(--color-surface-2) group transition-colors {n.read ? 'opacity-60' : ''}">
|
||||||
|
<a
|
||||||
|
href={n.link || '/'}
|
||||||
|
onclick={() => markRead(n.id)}
|
||||||
|
class="flex-1 p-4 min-w-0"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if !n.read}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium text-sm">{n.title}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-1">{n.message}</p>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => dismiss(n.id)}
|
||||||
|
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
getUserStats,
|
getUserStats,
|
||||||
allProgress,
|
allProgress,
|
||||||
getBooksBySlugs
|
getBooksBySlugs,
|
||||||
|
getUserById
|
||||||
} from '$lib/server/pocketbase';
|
} from '$lib/server/pocketbase';
|
||||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
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
|
// Run all three independent groups concurrently
|
||||||
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
|
const [userRecord, sessionsResult, statsResult, historyResult, freshUserResult] = await Promise.allSettled([
|
||||||
fetchUserRecord(),
|
fetchUserRecord(),
|
||||||
listUserSessions(locals.user.id),
|
listUserSessions(locals.user.id),
|
||||||
getUserStats(locals.sessionId, locals.user.id),
|
getUserStats(locals.sessionId, locals.user.id),
|
||||||
fetchHistory()
|
fetchHistory(),
|
||||||
|
fetchFreshUser()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (userRecord.status === 'rejected')
|
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) });
|
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
|
||||||
if (historyResult.status === 'rejected')
|
if (historyResult.status === 'rejected')
|
||||||
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
|
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
|
||||||
|
|
||||||
const { avatarUrl = null, email = null, polarCustomerId = null } =
|
const { avatarUrl = null, email = null, polarCustomerId = null } =
|
||||||
userRecord.status === 'fulfilled' ? userRecord.value : {};
|
userRecord.status === 'fulfilled' ? userRecord.value : {};
|
||||||
const sessions =
|
const sessions =
|
||||||
@@ -66,12 +72,15 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
statsResult.status === 'fulfilled' ? statsResult.value : null;
|
statsResult.status === 'fulfilled' ? statsResult.value : null;
|
||||||
const history =
|
const history =
|
||||||
historyResult.status === 'fulfilled' ? historyResult.value : [];
|
historyResult.status === 'fulfilled' ? historyResult.value : [];
|
||||||
|
const freshUser =
|
||||||
|
freshUserResult.status === 'fulfilled' ? freshUserResult.value : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
email,
|
email,
|
||||||
polarCustomerId,
|
polarCustomerId,
|
||||||
|
notifyNewChapters: freshUser?.notify_new_chapters ?? true,
|
||||||
stats: stats ?? {
|
stats: stats ?? {
|
||||||
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
||||||
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
||||||
|
|||||||
@@ -238,6 +238,27 @@
|
|||||||
let pushState = $state<PushState>('unsupported');
|
let pushState = $state<PushState>('unsupported');
|
||||||
let pushError = $state('');
|
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(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
@@ -724,6 +745,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ── In-app notifications ──────────────────────────────────────────────── -->
|
||||||
|
<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)">In-app notifications</h2>
|
||||||
|
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||||
|
{#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}
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ── Push notifications ────────────────────────────────────────────────── -->
|
<!-- ── Push notifications ────────────────────────────────────────────────── -->
|
||||||
{#if pushState !== 'unsupported'}
|
{#if pushState !== 'unsupported'}
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user