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:
110
frontend/src/lib/components/ThumbnailRow.svelte
Normal file
110
frontend/src/lib/components/ThumbnailRow.svelte
Normal 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">
|
||||||
|
“{doc.summary}”
|
||||||
|
</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>
|
||||||
177
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
177
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
|
||||||
|
import ThumbnailRow from './ThumbnailRow.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseDoc = {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Liebe Anna',
|
||||||
|
originalFilename: 'liebe_anna.pdf',
|
||||||
|
documentDate: '1950-06-01',
|
||||||
|
location: 'Berlin',
|
||||||
|
summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
thumbnailKey: 'thumbnails/d1.jpg',
|
||||||
|
thumbnailGeneratedAt: '2026-04-01T12:00:00Z',
|
||||||
|
thumbnailAspect: 'PORTRAIT' as const,
|
||||||
|
pageCount: 2,
|
||||||
|
sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' },
|
||||||
|
receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }],
|
||||||
|
tags: [
|
||||||
|
{ id: 't1', name: 'Familie' },
|
||||||
|
{ id: 't2', name: 'Krieg' },
|
||||||
|
{ id: 't3', name: 'Reise' },
|
||||||
|
{ id: 't4', name: 'Arbeit' },
|
||||||
|
{ id: 't5', name: 'Zuhause' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ThumbnailRow', () => {
|
||||||
|
it('renders the title, date, location, and summary quote', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Liebe Anna');
|
||||||
|
expect(document.body.textContent).toContain('Berlin');
|
||||||
|
expect(document.body.textContent).toContain('Heute schreibe ich Dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to originalFilename when title is empty', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: { ...baseDoc, title: '' },
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('liebe_anna.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: true,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Out-going from Hans, other party is first receiver (Anna Schmidt)
|
||||||
|
expect(document.body.textContent).toContain('Anna Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the other-party name when showOtherParty=false (bilateral list)', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: false,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anna is the receiver; in a bilateral list we suppress party names.
|
||||||
|
expect(document.body.textContent).not.toContain('Anna Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders at most 3 tag chips and signals any remainder with "+N"', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||||
|
expect(chips.length).toBeLessThanOrEqual(3);
|
||||||
|
expect(document.body.textContent).toMatch(/\+2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders relative-year label derived from documentDate', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1950-06-01 → 2026-06-01 = 76 years
|
||||||
|
expect(document.body.textContent).toContain('vor 76 Jahren');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets border-l class based on isOut', () => {
|
||||||
|
const { unmount } = render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||||
|
expect(link.className).toContain('border-l-primary');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: false,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||||
|
expect(link.className).toContain('border-l-accent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a descriptive aria-label combining title and date', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||||
|
const label = link.getAttribute('aria-label') ?? '';
|
||||||
|
expect(label).toContain('Liebe Anna');
|
||||||
|
expect(label).toMatch(/1950/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: {
|
||||||
|
...baseDoc,
|
||||||
|
summary: 'safe <img src=x onerror="alert(1)"> text'
|
||||||
|
},
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
// No real img tag from the summary, the ConversationThumbnail img is fine.
|
||||||
|
const imgs = document.querySelectorAll('img[onerror]');
|
||||||
|
expect(imgs.length).toBe(0);
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing optional fields without crashing', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: {
|
||||||
|
id: 'n1',
|
||||||
|
title: 'Ohne Datum',
|
||||||
|
originalFilename: 'x.pdf',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
thumbnailAspect: 'PORTRAIT'
|
||||||
|
},
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false,
|
||||||
|
now: new Date('2026-06-01T00:00:00Z')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Ohne Datum');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user