feat(briefwechsel): add ConversationThumbnail with aspect + page badge
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 <noreply@anthropic.com>
This commit is contained in:
48
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
48
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { thumbnailUrl } from '$lib/thumbnails';
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
id: string;
|
||||||
|
thumbnailKey?: string;
|
||||||
|
thumbnailGeneratedAt?: string;
|
||||||
|
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||||
|
pageCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { doc }: { doc: Doc } = $props();
|
||||||
|
|
||||||
|
const url = $derived(thumbnailUrl(doc));
|
||||||
|
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
||||||
|
const pageCount = $derived(doc.pageCount ?? 1);
|
||||||
|
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="conv-thumb-tile"
|
||||||
|
data-aspect={aspect}
|
||||||
|
class="relative {tileClass} flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||||
|
>
|
||||||
|
{#if url}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="conv-thumb-skeleton"
|
||||||
|
class="h-full w-full bg-line/60 motion-safe:animate-pulse"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pageCount > 1}
|
||||||
|
<span
|
||||||
|
data-testid="conv-thumb-page-badge"
|
||||||
|
class="absolute top-1 right-1 rounded-full bg-primary/90 px-1.5 py-0.5 text-xs leading-none font-bold text-surface"
|
||||||
|
>{pageCount}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
110
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
110
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user