From 407bfbd5f1ed67d8f0ee19433665686f8bf1806f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:40:15 +0200 Subject: [PATCH] feat(briefwechsel): add ConversationThumbnail with aspect + page badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads thumbnailAspect from the backend and swaps between a 120×168 portrait tile and a 168×120 landscape tile so postcards and photos don't get cropped into a portrait frame. Shows a page-count badge top-right for multi-page PDFs, and a pulsing skeleton while the async thumbnail job hasn't run yet. URL assembly goes through the existing thumbnailUrl helper so cache-busting stays consistent with DocumentThumbnail. Refs #305 Co-Authored-By: Claude Opus 4.7 --- .../components/ConversationThumbnail.svelte | 48 ++++++++ .../ConversationThumbnail.svelte.spec.ts | 110 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 frontend/src/lib/components/ConversationThumbnail.svelte create mode 100644 frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts diff --git a/frontend/src/lib/components/ConversationThumbnail.svelte b/frontend/src/lib/components/ConversationThumbnail.svelte new file mode 100644 index 00000000..61c62c00 --- /dev/null +++ b/frontend/src/lib/components/ConversationThumbnail.svelte @@ -0,0 +1,48 @@ + + +
+ {#if url} + + {:else} + + {/if} + + {#if pageCount > 1} + {pageCount} + {/if} +
diff --git a/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts b/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts new file mode 100644 index 00000000..e8874710 --- /dev/null +++ b/frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; + +import ConversationThumbnail from './ConversationThumbnail.svelte'; + +afterEach(() => { + cleanup(); +}); + +describe('ConversationThumbnail', () => { + it('renders the thumbnail image with a cache-busting v= query param', () => { + render(ConversationThumbnail, { + doc: { + id: '1111', + thumbnailKey: 'thumbnails/1111.jpg', + thumbnailGeneratedAt: '2026-04-10T09:00:00Z', + thumbnailAspect: 'PORTRAIT', + pageCount: 1 + } + }); + + const img = document.querySelector('img') as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.getAttribute('src')).toContain('/api/documents/1111/thumbnail'); + expect(img!.getAttribute('src')).toContain('v='); + }); + + it('uses portrait dimensions when aspect is PORTRAIT', () => { + render(ConversationThumbnail, { + doc: { + id: 'p1', + thumbnailKey: 'thumbnails/p1.jpg', + thumbnailAspect: 'PORTRAIT', + pageCount: 1 + } + }); + + const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement; + expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT'); + }); + + it('uses landscape dimensions when aspect is LANDSCAPE', () => { + render(ConversationThumbnail, { + doc: { + id: 'l1', + thumbnailKey: 'thumbnails/l1.jpg', + thumbnailAspect: 'LANDSCAPE', + pageCount: 1 + } + }); + + const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement; + expect(tile.getAttribute('data-aspect')).toBe('LANDSCAPE'); + }); + + it('falls back to PORTRAIT when thumbnailAspect is missing', () => { + render(ConversationThumbnail, { + doc: { + id: 'n1', + thumbnailKey: 'thumbnails/n1.jpg' + } + }); + + const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement; + expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT'); + }); + + it('renders the page badge when pageCount is greater than 1', () => { + render(ConversationThumbnail, { + doc: { + id: 'm1', + thumbnailKey: 'thumbnails/m1.jpg', + thumbnailAspect: 'PORTRAIT', + pageCount: 4 + } + }); + + const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]') as HTMLElement; + expect(badge).not.toBeNull(); + expect(badge.textContent).toContain('4'); + }); + + it('hides the page badge when pageCount is 1 or missing', () => { + render(ConversationThumbnail, { + doc: { + id: 's1', + thumbnailKey: 'thumbnails/s1.jpg', + thumbnailAspect: 'PORTRAIT', + pageCount: 1 + } + }); + + const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]'); + expect(badge).toBeNull(); + }); + + it('renders a skeleton placeholder when no thumbnailKey is set yet', () => { + render(ConversationThumbnail, { + doc: { + id: 'blank', + thumbnailAspect: 'PORTRAIT' + } + }); + + expect(document.querySelector('img')).toBeNull(); + const skeleton = document.querySelector('[data-testid="conv-thumb-skeleton"]'); + expect(skeleton).not.toBeNull(); + expect(skeleton!.className).toContain('motion-safe:animate-pulse'); + }); +});