feat(#71): add notification bell + preferences UI
- NotificationBell.svelte: bell icon in header with unread badge, dropdown showing last 10 notifications, mark-all-read, click-outside close, keyboard Escape support, polls every PUBLIC_NOTIFICATION_POLL_MS ms - Wire NotificationBell into +layout.svelte between ThemeToggle and UserMenu (authenticated users only) - Profile page: add notification preferences card with notifyOnReply / notifyOnMention toggles, loaded via GET and saved via PUT /api/users/me/notification-preferences - i18n: de/en/es message keys for bell, notifications list, and preference labels Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -294,5 +294,16 @@
|
||||
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
|
||||
"enrich_back_to_list": "Zurück zur Liste",
|
||||
"comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!",
|
||||
"comment_start_discussion": "Diskussion starten →"
|
||||
"comment_start_discussion": "Diskussion starten →",
|
||||
"notification_bell_label": "Benachrichtigungen",
|
||||
"notification_bell_unread_label": "{count} ungelesene Benachrichtigungen",
|
||||
"notification_mark_all_read": "Alle gelesen",
|
||||
"notification_empty": "Keine neuen Benachrichtigungen",
|
||||
"notification_type_reply": "{actor} hat auf deinen Kommentar geantwortet",
|
||||
"notification_type_mention": "{actor} hat dich in einem Kommentar erwähnt",
|
||||
"notification_prefs_heading": "Benachrichtigungen",
|
||||
"notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet",
|
||||
"notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt",
|
||||
"mention_btn_label": "Person erwähnen",
|
||||
"mention_popup_empty": "Keine Nutzer gefunden"
|
||||
}
|
||||
|
||||
@@ -294,5 +294,16 @@
|
||||
"enrich_done_body": "All documents have been processed.",
|
||||
"enrich_back_to_list": "Back to list",
|
||||
"comment_empty_hint": "No comments yet – start the discussion!",
|
||||
"comment_start_discussion": "Start discussion →"
|
||||
"comment_start_discussion": "Start discussion →",
|
||||
"notification_bell_label": "Notifications",
|
||||
"notification_bell_unread_label": "{count} unread notifications",
|
||||
"notification_mark_all_read": "Mark all read",
|
||||
"notification_empty": "No new notifications",
|
||||
"notification_type_reply": "{actor} replied to your comment",
|
||||
"notification_type_mention": "{actor} mentioned you in a comment",
|
||||
"notification_prefs_heading": "Notifications",
|
||||
"notification_pref_reply": "Email when someone replies to my comment",
|
||||
"notification_pref_mention": "Email when someone mentions me in a comment",
|
||||
"mention_btn_label": "Mention person",
|
||||
"mention_popup_empty": "No users found"
|
||||
}
|
||||
|
||||
@@ -294,5 +294,16 @@
|
||||
"enrich_done_body": "Todos los documentos han sido procesados.",
|
||||
"enrich_back_to_list": "Volver a la lista",
|
||||
"comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!",
|
||||
"comment_start_discussion": "Iniciar discusión →"
|
||||
"comment_start_discussion": "Iniciar discusión →",
|
||||
"notification_bell_label": "Notificaciones",
|
||||
"notification_bell_unread_label": "{count} notificaciones sin leer",
|
||||
"notification_mark_all_read": "Marcar todo como leído",
|
||||
"notification_empty": "No hay notificaciones nuevas",
|
||||
"notification_type_reply": "{actor} respondió a tu comentario",
|
||||
"notification_type_mention": "{actor} te mencionó en un comentario",
|
||||
"notification_prefs_heading": "Notificaciones",
|
||||
"notification_pref_reply": "Correo cuando alguien responde a mi comentario",
|
||||
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
|
||||
"mention_btn_label": "Mencionar persona",
|
||||
"mention_popup_empty": "No se encontraron usuarios"
|
||||
}
|
||||
|
||||
304
frontend/src/lib/components/NotificationBell.svelte
Normal file
304
frontend/src/lib/components/NotificationBell.svelte
Normal file
@@ -0,0 +1,304 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { PUBLIC_NOTIFICATION_POLL_MS } from '$env/static/public';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type NotificationItem = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId: string;
|
||||
referenceId: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName: string;
|
||||
};
|
||||
|
||||
let notifications: NotificationItem[] = $state([]);
|
||||
let unreadCount = $derived(notifications.filter((n) => !n.read).length);
|
||||
let open = $state(false);
|
||||
|
||||
// DOM refs managed via attachments
|
||||
let bellButtonEl: HTMLButtonElement | null = null;
|
||||
let firstFocusableEl: HTMLButtonElement | null = null;
|
||||
|
||||
const pollMs = Number(PUBLIC_NOTIFICATION_POLL_MS) || 60000;
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
async function fetchNotifications() {
|
||||
try {
|
||||
const res = await fetch('/api/notifications?size=10');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notifications = data.content ?? [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDropdown() {
|
||||
open = !open;
|
||||
if (open) {
|
||||
await fetchNotifications();
|
||||
// defer focus until DOM updates
|
||||
setTimeout(() => {
|
||||
firstFocusableEl?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
open = false;
|
||||
bellButtonEl?.focus();
|
||||
}
|
||||
|
||||
async function markRead(notification: NotificationItem) {
|
||||
if (!notification.read) {
|
||||
try {
|
||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||
notification.read = true;
|
||||
} catch (e) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
}
|
||||
const url = `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||
closeDropdown();
|
||||
goto(url);
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||
for (const n of notifications) {
|
||||
n.read = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to mark all notifications as read', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.stopPropagation();
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment: stores the element reference for the bell button
|
||||
function attachBellButton(node: HTMLButtonElement) {
|
||||
bellButtonEl = node;
|
||||
return () => {
|
||||
bellButtonEl = null;
|
||||
};
|
||||
}
|
||||
|
||||
// Attachment: stores the element reference for the first focusable element in the dropdown
|
||||
function attachFirstFocusable(node: HTMLButtonElement) {
|
||||
firstFocusableEl = node;
|
||||
return () => {
|
||||
firstFocusableEl = null;
|
||||
};
|
||||
}
|
||||
|
||||
// Attachment: closes dropdown when clicking outside the wrapper element
|
||||
function attachClickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
if (open) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
|
||||
function relativeTime(isoString: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoString).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin < 60) return `vor ${diffMin} Min.`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `vor ${diffH} Std.`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchNotifications();
|
||||
intervalId = setInterval(fetchNotifications, pollMs);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(intervalId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="relative" {@attach attachClickOutside}>
|
||||
<!-- Bell button -->
|
||||
<button
|
||||
{@attach attachBellButton}
|
||||
type="button"
|
||||
onclick={toggleDropdown}
|
||||
aria-label={unreadCount > 0
|
||||
? m.notification_bell_unread_label({ count: unreadCount })
|
||||
: m.notification_bell_label()}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
class="relative rounded-sm p-2 text-ink-2 transition-colors hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<!-- Bell SVG -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Unread badge -->
|
||||
{#if unreadCount > 0}
|
||||
<span
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg"
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if open}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={m.notification_bell_label()}
|
||||
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.notification_bell_label()}
|
||||
</span>
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
{@attach attachFirstFocusable}
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notification list -->
|
||||
{#if notifications.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-ink-3 opacity-40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<span>{m.notification_empty()}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<ul role="list">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => markRead(notification)}
|
||||
onkeydown={(e) => e.key === 'Enter' && markRead(notification)}
|
||||
class="flex cursor-pointer items-start gap-3 border-b border-line px-4 py-3 last:border-b-0 hover:bg-canvas
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Text + time -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-snug text-ink">
|
||||
{notification.type === 'REPLY'
|
||||
? m.notification_type_reply({ actor: notification.actorName })
|
||||
: m.notification_type_mention({ actor: notification.actorName })}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Unread dot -->
|
||||
{#if !notification.read}
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-label="ungelesen"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@ import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import NotificationBell from '$lib/components/NotificationBell.svelte';
|
||||
import AppNav from './AppNav.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
|
||||
@@ -52,6 +53,11 @@ const userInitials = $derived.by(() => {
|
||||
<!-- Theme toggle -->
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Notification bell (authenticated users only) -->
|
||||
{#if data?.user}
|
||||
<NotificationBell />
|
||||
{/if}
|
||||
|
||||
<!-- User menu -->
|
||||
<UserMenu userInitials={userInitials} />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user };
|
||||
const apiBase = () => env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`);
|
||||
const notificationPrefs = res.ok ? await res.json() : null;
|
||||
return { user: locals.user, notificationPrefs };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -50,5 +55,26 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
return { passwordSuccess: true };
|
||||
},
|
||||
|
||||
updateNotificationPrefs: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
notifyOnReply: formData.get('notifyOnReply') === 'true',
|
||||
notifyOnMention: formData.get('notifyOnMention') === 'true'
|
||||
};
|
||||
|
||||
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return fail(res.status, { prefsError: getErrorMessage(data?.code) });
|
||||
}
|
||||
|
||||
return { prefsSuccess: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonalInfoForm from './PersonalInfoForm.svelte';
|
||||
import PasswordChangeForm from './PasswordChangeForm.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let notifyOnReply = $state(untrack(() => data.notificationPrefs?.notifyOnReply ?? false));
|
||||
let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMention ?? false));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
@@ -30,4 +35,54 @@ let { data, form } = $props();
|
||||
<PersonalInfoForm user={data.user} form={form} />
|
||||
<PasswordChangeForm form={form} />
|
||||
</div>
|
||||
|
||||
<!-- Notification preferences -->
|
||||
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.notification_prefs_heading()}
|
||||
</h2>
|
||||
|
||||
{#if form?.prefsSuccess}
|
||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||
{m.profile_saved()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.prefsError}
|
||||
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{form.prefsError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/updateNotificationPrefs" use:enhance>
|
||||
<input type="hidden" name="notifyOnReply" value={notifyOnReply} />
|
||||
<input type="hidden" name="notifyOnMention" value={notifyOnMention} />
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={notifyOnReply}
|
||||
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||
/>
|
||||
<span class="text-sm text-ink">{m.notification_pref_reply()}</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={notifyOnMention}
|
||||
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||
/>
|
||||
<span class="text-sm text-ink">{m.notification_pref_mention()}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user