refactor(conversations): extract filter bar and timeline sub-components

Split conversations/+page.svelte (346 lines) into:
- ConversationFilterBar.svelte: person A/B typeaheads, swap button, date range, sort toggle
- ConversationTimeline.svelte: summary bar, chat bubbles, year dividers, new-doc link

Page drops from 346 → ~70 lines; navigation logic and filter state stay in the page.

Part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-26 12:22:38 +01:00
parent 1facf9cd60
commit 90e94b350a
3 changed files with 326 additions and 261 deletions

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import ConversationFilterBar from './ConversationFilterBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
let { data } = $props();
@@ -14,14 +14,6 @@ let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
const documentYears = $derived(
data.documents
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
.filter((y): y is number => y !== null)
);
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
// Sync with server data after navigation
$effect(() => {
senderId = data.filters.senderId;
@@ -52,17 +44,6 @@ function swapPersons() {
receiverId = tmp;
applyFilters();
}
const enrichedDocuments = $derived(
data.documents.map((doc, i) => {
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
const prevYear =
i > 0 && data.documents[i - 1].documentDate
? new Date(data.documents[i - 1].documentDate!).getFullYear()
: null;
return { doc, year, showYearDivider: year !== null && year !== prevYear };
})
);
</script>
<div class="mx-auto max-w-5xl px-4 py-10">
@@ -74,124 +55,18 @@ const enrichedDocuments = $derived(
</p>
</div>
<!-- FILTER BAR -->
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={data.initialValues.senderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => applyFilters()}
/>
</div>
<!-- Swap button — always rendered to hold grid column width on desktop.
On mobile: hidden (display:none) when no persons selected so no gap appears.
On desktop: invisible (visibility:hidden) when no persons so both 1fr columns stay equal. -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={swapPersons}
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={data.initialValues.receiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => applyFilters()}
/>
</div>
</div>
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
onchange={() => applyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<!-- Date To -->
<div>
<label
for="dateTo"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
onchange={() => applyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
onclick={toggleSort}
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>
<ConversationFilterBar
bind:senderId={senderId}
bind:receiverId={receiverId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
onswapPersons={swapPersons}
/>
<!-- RESULTS LIST SECTION -->
{#if !senderId || !receiverId}
@@ -219,127 +94,11 @@ const enrichedDocuments = $derived(
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div>
{:else}
<!-- Summary bar -->
<div class="mb-4 flex items-center justify-between">
{#if yearFrom !== null && yearTo !== null}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{m.conv_summary({ count: data.documents.length, yearFrom, yearTo })}
</p>
{:else}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{data.documents.length}
</p>
{/if}
{#if data.canWrite}
<a
data-testid="conv-new-doc-link"
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
></path>
</svg>
{m.conv_new_doc_link()}
</a>
{/if}
</div>
<!-- CHAT CONTAINER -->
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
></div>
<div class="p-6 md:p-8">
<div class="relative z-10 flex flex-col gap-4">
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
{#if showYearDivider}
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
<div class="flex-grow border-t border-line"></div>
<span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase"
>{year}</span
>
<div class="flex-grow border-t border-line"></div>
</div>
{/if}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
<!-- Bubble Group -->
<div
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR -->
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
<div
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
{isRight
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}
</div>
</div>
<!-- BUBBLE CARD -->
<a
href="/documents/{doc.id}"
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
{isRight
? 'rounded-br-none border-primary bg-primary text-primary-fg'
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
>
<!-- Header -->
<div class="mb-2 flex items-start justify-between gap-4">
<h3
class="font-serif text-sm leading-snug font-medium {isRight
? 'text-primary-fg'
: 'text-ink'}"
>
{doc.title || doc.originalFilename}
</h3>
<!-- Status Dot -->
<span
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
{doc.status === 'UPLOADED'
? 'bg-accent'
: 'bg-yellow-400'}"
title={doc.status}
>
</span>
</div>
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
? 'text-primary-fg/70'
: 'text-ink-2'}"
>
<span class="flex items-center">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
{#if doc.location}
<span class="flex items-center">
{doc.location}
</span>
{/if}
</div>
</a>
</div>
</div>
{/each}
</div>
</div>
</div>
<ConversationTimeline
documents={data.documents}
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
/>
{/if}
</div>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
senderId = $bindable(''),
receiverId = $bindable(''),
fromDate = $bindable(''),
toDate = $bindable(''),
sortDir = $bindable('DESC'),
initialSenderName = '',
initialReceiverName = '',
onapplyFilters,
ontoggleSort,
onswapPersons
}: {
senderId?: string;
receiverId?: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
initialSenderName?: string;
initialReceiverName?: string;
onapplyFilters: () => void;
ontoggleSort: () => void;
onswapPersons: () => void;
} = $props();
</script>
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={initialSenderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
<!-- Swap button -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={onswapPersons}
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={initialReceiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
</div>
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<!-- Date To -->
<div>
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
onclick={ontoggleSort}
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let {
documents,
senderId,
receiverId,
canWrite
}: {
documents: {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
status: string;
sender?: { id: string; firstName: string; lastName: string } | null;
}[];
senderId: string;
receiverId: string;
canWrite: boolean;
} = $props();
const documentYears = $derived(
documents
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
.filter((y): y is number => y !== null)
);
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
const enrichedDocuments = $derived(
documents.map((doc, i) => {
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
const prevYear =
i > 0 && documents[i - 1].documentDate
? new Date(documents[i - 1].documentDate!).getFullYear()
: null;
return { doc, year, showYearDivider: year !== null && year !== prevYear };
})
);
</script>
<!-- Summary bar -->
<div class="mb-4 flex items-center justify-between">
{#if yearFrom !== null && yearTo !== null}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{m.conv_summary({ count: documents.length, yearFrom, yearTo })}
</p>
{:else}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{documents.length}
</p>
{/if}
{#if canWrite}
<a
data-testid="conv-new-doc-link"
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
></path>
</svg>
{m.conv_new_doc_link()}
</a>
{/if}
</div>
<!-- CHAT CONTAINER -->
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
></div>
<div class="p-6 md:p-8">
<div class="relative z-10 flex flex-col gap-4">
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
{#if showYearDivider}
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
<div class="flex-grow border-t border-line"></div>
<span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase"
>{year}</span
>
<div class="flex-grow border-t border-line"></div>
</div>
{/if}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
<!-- Bubble Group -->
<div
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR -->
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
<div
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
{isRight
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}
</div>
</div>
<!-- BUBBLE CARD -->
<a
href="/documents/{doc.id}"
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
{isRight
? 'rounded-br-none border-primary bg-primary text-primary-fg'
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
>
<!-- Header -->
<div class="mb-2 flex items-start justify-between gap-4">
<h3
class="font-serif text-sm leading-snug font-medium {isRight
? 'text-primary-fg'
: 'text-ink'}"
>
{doc.title || doc.originalFilename}
</h3>
<!-- Status Dot -->
<span
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
{doc.status === 'UPLOADED' ? 'bg-accent' : 'bg-yellow-400'}"
title={doc.status}
>
</span>
</div>
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
? 'text-primary-fg/70'
: 'text-ink-2'}"
>
<span class="flex items-center">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
{#if doc.location}
<span class="flex items-center">
{doc.location}
</span>
{/if}
</div>
</a>
</div>
</div>
{/each}
</div>
</div>
</div>