Two items flagged as blockers in PR #288 review: - Markus + Sara: "Mehr laden" calls GET /api/dashboard/activity?offset=N but the backend's DashboardController only accepts `limit` — `offset` was silently ignored, and every click re-fetched the same top-40 rows. Rather than add backend offset/cursor support in this PR (scope creep), remove the Load-more UI and defer pagination to a follow-up issue. 40 items covers the default case; the feature can come back with proper backend support and its own tests. - Markus + Sara: ?/dismiss and ?/mark-all form actions were dead code — the UI calls `onMarkRead` / `onMarkAllRead` callbacks (→ singleton → raw PATCH) and never submits either form. Delete both actions and their tests. Using the form-action path would require deprecating the NotificationBell's raw-PATCH as well — that's tracked separately as #286. The Dismiss markup split from the previous commit stands on its own. Part of #285, address PR #288 review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
4.6 KiB
Svelte
155 lines
4.6 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/state';
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
import { notificationStore, type NotificationItem } from '$lib/stores/notifications.svelte';
|
|
import ChronikFuerDichBox from '$lib/components/chronik/ChronikFuerDichBox.svelte';
|
|
import ChronikFilterPills from '$lib/components/chronik/ChronikFilterPills.svelte';
|
|
import ChronikTimeline from '$lib/components/chronik/ChronikTimeline.svelte';
|
|
import ChronikEmptyState from '$lib/components/chronik/ChronikEmptyState.svelte';
|
|
import ChronikErrorCard from '$lib/components/chronik/ChronikErrorCard.svelte';
|
|
import type { components } from '$lib/generated/api';
|
|
import type { FilterValue } from './+page.server';
|
|
|
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
|
|
|
interface Props {
|
|
data: {
|
|
filter: FilterValue;
|
|
activityFeed: ActivityFeedItemDTO[];
|
|
unreadNotifications: components['schemas']['NotificationDTO'][];
|
|
loadError: string | null;
|
|
};
|
|
}
|
|
|
|
const { data }: Props = $props();
|
|
|
|
// Mirror the current filter into a local state we can update on pill change.
|
|
// The effect syncs whenever the server-loaded filter changes (e.g. after goto).
|
|
// eslint-disable-next-line svelte/prefer-writable-derived -- we need this mutable for onFilterChange optimism before goto() resolves
|
|
let activeFilter = $state<FilterValue>('alle');
|
|
$effect(() => {
|
|
activeFilter = data.filter;
|
|
});
|
|
|
|
// Prefer the live SSE singleton for unread items so newly arriving mentions
|
|
// prepend without a reload. On first mount, seed from the server-loaded unread
|
|
// set if the singleton hasn't populated yet.
|
|
onMount(() => {
|
|
notificationStore.init();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
notificationStore.destroy();
|
|
});
|
|
|
|
const liveUnread = $derived<NotificationItem[]>(
|
|
notificationStore.notifications.filter((n) => !n.read)
|
|
);
|
|
|
|
const seedUnread = $derived<NotificationItem[]>(
|
|
data.unreadNotifications
|
|
.filter((n): n is typeof n & { documentId: string; referenceId: string } =>
|
|
Boolean(n.documentId && n.referenceId)
|
|
)
|
|
.map((n) => ({
|
|
id: n.id,
|
|
type: n.type,
|
|
documentId: n.documentId,
|
|
documentTitle: n.documentTitle ?? null,
|
|
referenceId: n.referenceId,
|
|
annotationId: n.annotationId ?? null,
|
|
read: n.read,
|
|
createdAt: n.createdAt,
|
|
actorName: n.actorName ?? ''
|
|
}))
|
|
);
|
|
|
|
// If the singleton has any data (including zero after mark-all), trust it;
|
|
// otherwise fall back to the SSR-seeded unread set.
|
|
const unread = $derived<NotificationItem[]>(
|
|
notificationStore.notifications.length > 0 ? liveUnread : seedUnread
|
|
);
|
|
|
|
async function onFilterChange(v: FilterValue) {
|
|
activeFilter = v;
|
|
const url = new URL(page.url);
|
|
if (v === 'alle') url.searchParams.delete('filter');
|
|
else url.searchParams.set('filter', v);
|
|
await goto(`${url.pathname}${url.search}`, {
|
|
keepFocus: true,
|
|
noScroll: true,
|
|
replaceState: true
|
|
});
|
|
}
|
|
|
|
async function onMarkRead(n: NotificationItem) {
|
|
await notificationStore.markRead(n);
|
|
}
|
|
|
|
async function onMarkAllRead() {
|
|
await notificationStore.markAllRead();
|
|
}
|
|
|
|
const displayFeed = $derived<ActivityFeedItemDTO[]>(
|
|
(() => {
|
|
const merged = data.activityFeed;
|
|
switch (activeFilter) {
|
|
case 'alle':
|
|
return merged;
|
|
case 'fuer-dich':
|
|
return merged.filter((i) => i.kind === 'MENTION_CREATED' || i.youMentioned);
|
|
case 'hochgeladen':
|
|
return merged.filter((i) => i.kind === 'FILE_UPLOADED');
|
|
case 'transkription':
|
|
return merged.filter(
|
|
(i) =>
|
|
i.kind === 'TEXT_SAVED' ||
|
|
i.kind === 'BLOCK_REVIEWED' ||
|
|
i.kind === 'ANNOTATION_CREATED'
|
|
);
|
|
case 'kommentare':
|
|
return merged.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED');
|
|
}
|
|
})()
|
|
);
|
|
|
|
const isEmpty = $derived(displayFeed.length === 0);
|
|
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
|
|
data.activityFeed.length === 0 ? 'first-run' : 'filter-empty'
|
|
);
|
|
|
|
function retry() {
|
|
location.reload();
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{m.chronik_page_title()}</title>
|
|
</svelte:head>
|
|
|
|
<main class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
|
<header class="mb-6 flex items-baseline justify-between">
|
|
<h1 class="font-serif text-2xl text-ink">{m.chronik_page_title()}</h1>
|
|
</header>
|
|
|
|
{#if data.loadError === 'activity'}
|
|
<ChronikErrorCard onRetry={retry} />
|
|
{:else}
|
|
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
|
|
|
<div class="mt-6">
|
|
<ChronikFilterPills value={activeFilter} onChange={onFilterChange} />
|
|
</div>
|
|
|
|
{#if isEmpty}
|
|
<div class="mt-8">
|
|
<ChronikEmptyState variant={emptyVariant} />
|
|
</div>
|
|
{:else}
|
|
<ChronikTimeline items={displayFeed} />
|
|
{/if}
|
|
{/if}
|
|
</main>
|