feat(briefwechsel): add ThumbnailRow for the new correspondence row layout

Combines ConversationThumbnail with a quote-styled summary, truncated
meta line, and up to three tag chips (the rest collapsed into "+N").
The colored left border tells a reader at a glance whether this
letter left or entered the perspective person's mailbox — replacing
the previous status dot + script-type icons that were too busy for
the list view. Relative-year label ("vor 76 Jahren") is derived from
documentDate so the list carries temporal context without a full
date column.

Rendering rules:
- title falls back to originalFilename when empty
- summary uses a text expression, never {@html}, so inline markup
  in the summary field is escaped (XSS regression test locks this)
- focus-visible outline + focus-within hover keep keyboard-only
  users in sync with mouse hover feedback
- aria-label always pairs title with the formatted date so screen
  readers hear both identifiers

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-23 14:45:08 +02:00
committed by marcel
parent 407bfbd5f1
commit cc118ffb16
2 changed files with 287 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte';
import { formatDate } from '$lib/utils/date';
import { relativeYearsDe } from '$lib/relativeTime';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Doc = {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
summary?: string;
contentType?: string;
thumbnailKey?: string;
thumbnailGeneratedAt?: string;
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
pageCount?: number;
sender?: Person | null;
receivers?: Person[];
tags?: Tag[];
};
let {
doc,
isOut,
showOtherParty,
now
}: {
doc: Doc;
isOut: boolean;
showOtherParty: boolean;
now?: Date;
} = $props();
const title = $derived(doc.title || doc.originalFilename);
const displayedTags = $derived((doc.tags ?? []).slice(0, 3));
const hiddenTagCount = $derived(Math.max(0, (doc.tags ?? []).length - 3));
const otherPartyName = $derived(
showOtherParty
? isOut
? (doc.receivers?.[0]?.displayName ?? '')
: (doc.sender?.displayName ?? '')
: ''
);
const relativeYearLabel = $derived(
doc.documentDate
? relativeYearsDe(new Date(doc.documentDate + 'T12:00:00'), now ?? new Date())
: ''
);
const ariaLabel = $derived(
`${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
);
</script>
<a
href={`/documents/${doc.id}`}
aria-label={ariaLabel}
class="group flex min-h-[120px] items-start gap-3 border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 focus-within:bg-muted hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
class:border-l-primary={isOut}
class:border-l-accent={!isOut}
>
<ConversationThumbnail doc={doc} />
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-baseline justify-between gap-3">
<div class="min-w-0 flex-1 truncate text-sm font-bold text-ink">
{title}
</div>
{#if relativeYearLabel}
<div class="shrink-0 text-xs text-ink-3">{relativeYearLabel}</div>
{/if}
</div>
{#if doc.summary}
<div class="line-clamp-2 text-sm text-ink-2 italic">
&ldquo;{doc.summary}&rdquo;
</div>
{/if}
<div class="flex flex-wrap items-center gap-x-[6px] gap-y-1 text-xs text-ink-3">
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
{#if doc.location}
<span class="text-line">·</span>
<span>{doc.location}</span>
{/if}
{#if otherPartyName}
<span class="text-line">·</span>
<span>{otherPartyName}</span>
{/if}
</div>
{#if displayedTags.length > 0}
<div class="flex flex-wrap items-center gap-1 pt-0.5">
{#each displayedTags as tag (tag.id)}
<span
data-testid="thumb-row-tag"
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-xs text-ink-2"
>{tag.name}</span
>
{/each}
{#if hiddenTagCount > 0}
<span class="text-xs font-bold text-ink-3">+{hiddenTagCount}</span>
{/if}
</div>
{/if}
</div>
</a>