feat(korrespondenz): wire up +page.svelte orchestrator with new components

Compose CorrespondenzPersonBar, CorrespondenzFilterControls, SinglePersonHintBar,
CorrespondenzEmptyState, and updated ConversationTimeline. Add localStorage
recent-persons persistence on applyFilters, single-person mode gate, and
canWrite derived from user groups in load function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-30 12:59:33 +02:00
parent 3addc72693
commit 4f5f8255a1
2 changed files with 80 additions and 33 deletions

View File

@@ -1,13 +1,18 @@
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { createApiClient } from '$lib/api.server'; import { createApiClient } from '$lib/api.server';
export async function load({ url, fetch }) { export async function load({ url, fetch, locals }) {
const senderId = url.searchParams.get('senderId') || ''; const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || ''; const receiverId = url.searchParams.get('receiverId') || '';
const from = url.searchParams.get('from') || ''; const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || ''; const to = url.searchParams.get('to') || '';
const dir = url.searchParams.get('dir') || 'DESC'; const dir = url.searchParams.get('dir') || 'DESC';
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const api = createApiClient(fetch); const api = createApiClient(fetch);
let documents: components['schemas']['Document'][] = []; let documents: components['schemas']['Document'][] = [];
@@ -56,6 +61,7 @@ export async function load({ url, fetch }) {
return { return {
documents, documents,
canWrite,
initialValues: { senderName, receiverName }, initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir } filters: { senderId, receiverId, from, to, dir }
}; };

View File

@@ -2,9 +2,12 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity'; import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js'; import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
import ConversationFilterBar from './ConversationFilterBar.svelte'; import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte'; import ConversationTimeline from './ConversationTimeline.svelte';
import CorrespondenzEmptyState from './CorrespondenzEmptyState.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props(); let { data } = $props();
@@ -14,6 +17,10 @@ let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to)); let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir)); let sortDir = $state(untrack(() => data.filters.dir));
// Derived name states — kept as reactive copies so ConversationTimeline always has current names
let senderName = $state(untrack(() => data.initialValues.senderName));
let receiverName = $state(untrack(() => data.initialValues.receiverName));
// Sync with server data after navigation // Sync with server data after navigation
$effect(() => { $effect(() => {
senderId = data.filters.senderId; senderId = data.filters.senderId;
@@ -21,9 +28,32 @@ $effect(() => {
fromDate = data.filters.from; fromDate = data.filters.from;
toDate = data.filters.to; toDate = data.filters.to;
sortDir = data.filters.dir; sortDir = data.filters.dir;
senderName = data.initialValues.senderName;
receiverName = data.initialValues.receiverName;
}); });
const isSinglePerson = $derived(!!senderId && !receiverId);
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
const MAX_RECENT = 5;
function persistRecentPerson(id: string, name: string) {
if (!id) return;
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
const existing: { id: string; name: string }[] = raw ? JSON.parse(raw) : [];
const filtered = existing.filter((p) => p.id !== id);
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
} catch {
// localStorage unavailable — silently ignore
}
}
function applyFilters() { function applyFilters() {
// Persist to recent persons when a person is selected
if (senderId && senderName) persistRecentPerson(senderId, senderName);
const params = new SvelteURLSearchParams(); const params = new SvelteURLSearchParams();
if (senderId) params.set('senderId', senderId); if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId); if (receiverId) params.set('receiverId', receiverId);
@@ -44,54 +74,63 @@ function swapPersons() {
receiverId = tmp; receiverId = tmp;
applyFilters(); applyFilters();
} }
function selectPerson(id: string) {
senderId = id;
receiverId = '';
applyFilters();
}
</script> </script>
<div class="mx-auto max-w-5xl px-4 py-10"> <div class="mx-auto max-w-3xl px-4 py-8">
<!-- Page Header --> <!-- Page Header -->
<div class="mb-8 border-b border-ink/10 pb-4"> <div class="mb-6 border-b border-[#E0DDD6] pb-4">
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1> <h1 class="font-serif text-2xl font-semibold text-[#002850]">{m.conv_heading()}</h1>
<p class="mt-2 font-sans text-sm text-ink-2"> <p class="mt-1 font-sans text-sm text-[#666]">
{m.conv_subtitle()} {m.conv_subtitle()}
</p> </p>
</div> </div>
<ConversationFilterBar <!-- Filter strip: Row 1 — persons -->
<CorrespondenzPersonBar
bind:senderId={senderId} bind:senderId={senderId}
bind:receiverId={receiverId} bind:receiverId={receiverId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
initialSenderName={data.initialValues.senderName} initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName} initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters} onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
onswapPersons={swapPersons} onswapPersons={swapPersons}
/> />
<!-- RESULTS LIST SECTION --> <!-- Filter strip: Row 2 — date/sort/count -->
{#if !senderId || !receiverId} <CorrespondenzFilterControls
<div senderId={senderId}
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center" bind:fromDate={fromDate}
> bind:toDate={toDate}
<div class="mb-4 rounded-full bg-muted p-4 text-ink"> bind:sortDir={sortDir}
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" documentCount={data.documents.length}
><path onapplyFilters={applyFilters}
stroke-linecap="round" ontoggleSort={toggleSort}
stroke-linejoin="round" />
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" <!-- Single-person hint bar -->
/></svg {#if isSinglePerson}
> <SinglePersonHintBar
</div> senderName={senderName}
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p> fromDate={fromDate || undefined}
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p> toDate={toDate || undefined}
</div> sortDir={sortDir}
/>
{/if}
<!-- Results -->
{#if !senderId}
<CorrespondenzEmptyState onSelectPerson={selectPerson} />
{:else if data.documents.length === 0} {:else if data.documents.length === 0}
<div <div
class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface py-24 text-center shadow-sm" class="flex flex-col items-center justify-center rounded-sm border border-[#E0DDD6] bg-[#F7F5F2] py-24 text-center shadow-sm"
> >
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p> <p class="font-serif text-[#333]">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p> <p class="mt-2 text-sm text-[#888]">{m.conv_no_results_text()}</p>
</div> </div>
{:else} {:else}
<ConversationTimeline <ConversationTimeline
@@ -99,6 +138,8 @@ function swapPersons() {
senderId={senderId} senderId={senderId}
receiverId={receiverId} receiverId={receiverId}
canWrite={data.canWrite} canWrite={data.canWrite}
senderName={senderName}
receiverName={receiverName}
/> />
{/if} {/if}
</div> </div>