refactor(briefwechsel): ConversationTimeline renders ThumbnailRow per letter

Drops the inline row markup, arrow icons, status-dot helper, and the
otherPartyName helper that only fed it. Each visible row is now a
ThumbnailRow, which owns its own aria-label, border color, meta and
tag rendering. The year-divider and "new document" footer are
untouched — they were always intended to stay as timeline chrome.

Also widens the documents prop shape to include the summary, tags
and thumbnail metadata that ThumbnailRow consumes; the backend
already returns these fields via the Document schema so no server
change was required.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-23 14:47:41 +02:00
parent dc60d27f20
commit 80728200c6

View File

@@ -1,7 +1,10 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
import DistributionBar from '$lib/components/DistributionBar.svelte';
import ThumbnailRow from '$lib/components/ThumbnailRow.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
interface Props {
documents: {
@@ -10,14 +13,15 @@ interface Props {
originalFilename: string;
documentDate?: string;
location?: string;
status: string;
sender?: {
id: string;
firstName?: string | null;
lastName: string;
displayName: string;
} | null;
receivers?: { id: string; firstName?: string | null; lastName: string; displayName: string }[];
summary?: string;
contentType?: string;
thumbnailKey?: string;
thumbnailGeneratedAt?: string;
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
pageCount?: number;
sender?: Person | null;
receivers?: Person[];
tags?: Tag[];
}[];
senderId: string;
receiverId?: string;
@@ -54,25 +58,7 @@ const outCount = $derived(documents.filter((d) => d.sender?.id === senderId).len
const inCount = $derived(documents.length - outCount);
const isBilateral = $derived(!!senderId && !!receiverId);
function statusDotClass(status: string): string {
const map: Record<string, string> = {
PLACEHOLDER: 'bg-brand-sand',
UPLOADED: 'bg-brand-mint',
TRANSCRIBED: 'bg-brand-mint',
REVIEWED: 'bg-brand-navy/70',
ARCHIVED: 'bg-brand-navy'
};
return map[status] ?? 'bg-brand-sand';
}
function otherPartyName(doc: (typeof documents)[number]): string {
if (doc.sender?.id === senderId) {
const r = doc.receivers?.[0];
return r ? r.displayName : m.conv_no_party();
}
return doc.sender ? doc.sender.displayName : m.conv_no_party();
}
const showOtherParty = $derived(!receiverId);
const newDocUrl = $derived(
`/documents/new?senderId=${encodeURIComponent(senderId)}${receiverId ? `&receiverId=${encodeURIComponent(receiverId)}` : ''}`
@@ -100,50 +86,7 @@ const newDocUrl = $derived(
</div>
{/if}
<a
href="/documents/{doc.id}"
aria-label="{doc.title || doc.originalFilename}, {doc.documentDate
? formatDate(doc.documentDate)
: ''}"
class="group flex min-h-[44px] cursor-pointer items-center gap-[9px] border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 hover:bg-muted"
class:border-l-primary={isOut}
class:border-l-accent={!isOut}
>
<img
src={isOut
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
alt=""
aria-hidden="true"
class="h-4 w-4 shrink-0 opacity-60"
/>
<div class="min-w-0 flex-1">
<div class="mb-[2px] truncate text-sm font-bold text-ink">
{doc.title || doc.originalFilename}
</div>
<div class="flex items-center gap-[5px] text-sm text-ink-3">
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
{#if doc.location}
<span class="text-line">·</span>
<span>{doc.location}</span>
{/if}
{#if !receiverId}
<span class="text-line">·</span>
<span>{otherPartyName(doc)}</span>
{/if}
<span
class="ml-[3px] h-[6px] w-[6px] shrink-0 rounded-full {statusDotClass(doc.status)}"
title={doc.status}
></span>
</div>
</div>
<span
class="shrink-0 text-sm text-ink-3 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"></span
>
</a>
<ThumbnailRow doc={doc} isOut={isOut} showOtherParty={showOtherParty} />
{/each}
{#if canWrite}