- 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)
185 lines
5.7 KiB
Svelte
185 lines
5.7 KiB
Svelte
<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>
|