Files
familienarchiv/frontend/src/routes/notifications/+page.svelte
Marcel a5cc8fd16e feat(focus-rings): update interactive widgets to ring-focus-ring
PersonTypeahead, MentionEditor, PanelHistory, UserGroupsSection,
notifications filter buttons, CorrespondentSuggestionsDropdown:
replace ring-accent/ring-primary with ring-focus-ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 15:25:02 +02:00

280 lines
8.7 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
import { relativeTime, type NotificationItem } from '$lib/utils/notifications';
let { data } = $props();
let additionalNotifications = $state<NotificationItem[]>([]);
let loadMorePage = $state(1);
let isLoadingMore = $state(false);
const allNotifications = $derived([...data.notifications, ...additionalNotifications]);
const activeType = $derived(page.url.searchParams.get('type'));
const activeReadFilter = $derived(page.url.searchParams.get('read'));
const hasMore = $derived(loadMorePage < (data.totalPages ?? 1));
function setFilter(params: Record<string, string | null>) {
additionalNotifications = [];
loadMorePage = 1;
const url = new URL(page.url);
for (const [k, v] of Object.entries(params)) {
if (v === null) url.searchParams.delete(k);
else url.searchParams.set(k, v);
}
goto(url.toString());
}
async function loadMore() {
isLoadingMore = true;
try {
const typeParam = page.url.searchParams.get('type');
const readParam = page.url.searchParams.get('read');
let query = `page=${loadMorePage}&size=20`;
if (typeParam) query += `&type=${typeParam}`;
if (readParam !== null) query += `&read=${readParam}`;
const res = await fetch(`/api/notifications?${query}`);
if (res.ok) {
const json = await res.json();
additionalNotifications = [...additionalNotifications, ...(json.content ?? [])];
loadMorePage += 1;
}
} finally {
isLoadingMore = false;
}
}
async function navigateToNotification(n: NotificationItem) {
if (!n.read) {
await fetch(`/api/notifications/${n.id}/read`, { method: 'PATCH' });
}
const url = n.annotationId
? `/documents/${n.documentId}?commentId=${n.referenceId}&annotationId=${n.annotationId}`
: `/documents/${n.documentId}?commentId=${n.referenceId}`;
goto(url);
}
function typeBadgeLabel(type: NotificationItem['type']): string {
return type === 'MENTION' ? m.notification_filter_mention() : m.notification_filter_reply();
}
</script>
<svelte:head>
<title>{m.notification_history_heading()}</title>
</svelte:head>
<div class="min-h-screen bg-canvas">
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back link -->
<a
href="/"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-ink"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{m.btn_back_to_overview()}
</a>
<!-- Page header -->
<div class="mb-6 flex items-center justify-between">
<h1 class="font-serif text-2xl font-medium text-ink">
{m.notification_history_heading()}
</h1>
{#if data.unreadCount > 0}
<form method="POST" action="?/mark-all">
<button
type="submit"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
aria-label={m.notification_mark_all_read_aria()}
>
{m.notification_mark_all_read()}
</button>
</form>
{/if}
</div>
<!-- Filter pills -->
<div role="radiogroup" aria-label="Filter" class="mb-6 flex flex-wrap gap-2">
<!-- All -->
<button
role="radio"
aria-checked={activeType === null && activeReadFilter === null}
onclick={() => setFilter({ type: null, read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === null && activeReadFilter === null
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_all()}
</button>
<!-- Unread -->
<button
role="radio"
aria-checked={activeReadFilter === 'false'}
onclick={() => setFilter({ read: 'false', type: null })}
class={[
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeReadFilter === 'false'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_unread()}
{#if data.unreadCount > 0 && activeType === null && activeReadFilter === null}
<span
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 font-sans text-xs font-bold text-ink"
aria-hidden="true"
>
{data.unreadCount}
</span>
{/if}
</button>
<!-- Mention -->
<button
role="radio"
aria-checked={activeType === 'MENTION'}
onclick={() => setFilter({ type: 'MENTION', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'MENTION'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_mention()}
</button>
<!-- Reply -->
<button
role="radio"
aria-checked={activeType === 'REPLY'}
onclick={() => setFilter({ type: 'REPLY', read: null })}
class={[
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
activeType === 'REPLY'
? 'bg-primary text-primary-fg'
: 'bg-muted text-ink'
].join(' ')}
>
{m.notification_filter_reply()}
</button>
</div>
<!-- Notification list or empty state -->
{#if allNotifications.length === 0}
<div class="flex flex-col items-center gap-3 py-20 text-center">
<svg
class="h-10 w-10 text-ink-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
<h2 class="font-serif text-lg font-semibold text-ink">
{m.notification_empty_history()}
</h2>
<p class="max-w-xs font-sans text-sm text-ink-2">
{m.notification_empty_history_body()}
</p>
</div>
{:else}
<ul
role="list"
class="divide-y divide-line rounded-sm border border-line bg-canvas shadow-sm"
>
{#each allNotifications as n (n.id)}
<li class="relative bg-surface">
<a
href="/documents/{n.documentId}"
role="row"
class={[
'flex min-h-14 flex-col justify-center border-l-[3px] px-4 py-4 md:px-6 md:py-5',
'transition-colors hover:bg-accent-bg',
n.read
? 'border-l-transparent'
: 'border-l-accent'
].join(' ')}
aria-label={m.notification_row_aria({
actor: n.actorName,
type: typeBadgeLabel(n.type),
title: n.documentTitle ?? '',
time: relativeTime(n.createdAt),
readState: n.read ? m.notification_read_state_read() : m.notification_read_state_unread()
})}
onclick={(e) => {
e.preventDefault();
navigateToNotification(n);
}}
>
<!-- Unread dot indicator -->
{#if !n.read}
<span
class="absolute top-4 right-4 h-2 w-2 rounded-full bg-accent md:right-6"
aria-hidden="true"
></span>
{/if}
<!-- Line 1: actor name + type badge -->
<div class="flex items-center gap-2">
<span class="font-serif font-semibold text-ink">{n.actorName}</span>
<span
class="rounded-sm bg-muted px-2 py-0.5 font-sans text-xs tracking-wide text-ink-2 uppercase"
>
{typeBadgeLabel(n.type)}
</span>
</div>
<!-- Line 2: document title -->
{#if n.documentTitle}
<p
class="mt-0.5 font-serif text-sm text-ink hover:underline hover:decoration-accent"
>
{n.documentTitle}
</p>
{/if}
<!-- Line 3: relative time -->
<p class="mt-1 font-sans text-sm text-ink-3">
{relativeTime(n.createdAt)}
</p>
</a>
</li>
{/each}
</ul>
{/if}
<!-- Load more -->
{#if hasMore}
<button
onclick={loadMore}
disabled={isLoadingMore}
class="mt-6 w-full rounded-sm border border-line py-3 text-sm font-medium text-ink-2 transition-colors hover:bg-canvas disabled:opacity-50"
>
{isLoadingMore ? '…' : m.notification_load_more()}
</button>
{/if}
</div>
</div>