diff --git a/frontend/src/lib/components/ThumbnailRow.svelte b/frontend/src/lib/components/ThumbnailRow.svelte new file mode 100644 index 00000000..ef9a8ccb --- /dev/null +++ b/frontend/src/lib/components/ThumbnailRow.svelte @@ -0,0 +1,110 @@ + + + + + +
+
+
+ {title} +
+ {#if relativeYearLabel} +
{relativeYearLabel}
+ {/if} +
+ + {#if doc.summary} +
+ “{doc.summary}” +
+ {/if} + +
+ {doc.documentDate ? formatDate(doc.documentDate) : '—'} + {#if doc.location} + · + {doc.location} + {/if} + {#if otherPartyName} + · + {otherPartyName} + {/if} +
+ + {#if displayedTags.length > 0} +
+ {#each displayedTags as tag (tag.id)} + {tag.name} + {/each} + {#if hiddenTagCount > 0} + +{hiddenTagCount} + {/if} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts new file mode 100644 index 00000000..e3c6572d --- /dev/null +++ b/frontend/src/lib/components/ThumbnailRow.svelte.spec.ts @@ -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 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(''); + }); + + 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'); + }); +});