diff --git a/frontend/src/lib/components/DocumentThumbnail.svelte b/frontend/src/lib/components/DocumentThumbnail.svelte
new file mode 100644
index 00000000..fe7050c9
--- /dev/null
+++ b/frontend/src/lib/components/DocumentThumbnail.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+ {#if url}
+

+ {:else}
+
+ {/if}
+
diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts
index ed48f1a2..f2a0f090 100644
--- a/frontend/src/lib/generated/api.ts
+++ b/frontend/src/lib/generated/api.ts
@@ -1383,6 +1383,9 @@ export interface components {
filePath?: string;
contentType?: string;
fileHash?: string;
+ thumbnailKey?: string;
+ /** Format: date-time */
+ thumbnailGeneratedAt?: string;
originalFilename: string;
/** @enum {string} */
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
diff --git a/frontend/src/lib/thumbnails.test.ts b/frontend/src/lib/thumbnails.test.ts
new file mode 100644
index 00000000..aad48268
--- /dev/null
+++ b/frontend/src/lib/thumbnails.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { thumbnailUrl } from './thumbnails';
+
+describe('thumbnailUrl', () => {
+ it('returns null when thumbnailKey is undefined', () => {
+ expect(thumbnailUrl({ id: 'abc' })).toBeNull();
+ });
+
+ it('returns url without version param when thumbnailKey present but generatedAt missing', () => {
+ expect(thumbnailUrl({ id: 'abc', thumbnailKey: 'thumbnails/abc.jpg' })).toBe(
+ '/api/documents/abc/thumbnail'
+ );
+ });
+
+ it('appends encoded cache-bust param when generatedAt present', () => {
+ const url = thumbnailUrl({
+ id: 'abc',
+ thumbnailKey: 'thumbnails/abc.jpg',
+ thumbnailGeneratedAt: '2026-04-22T20:41:15.123456'
+ });
+ expect(url).toBe('/api/documents/abc/thumbnail?v=2026-04-22T20%3A41%3A15.123456');
+ });
+
+ it('different generatedAt produces different URL — enables cache-bust on file replace', () => {
+ const a = thumbnailUrl({
+ id: 'x',
+ thumbnailKey: 'thumbnails/x.jpg',
+ thumbnailGeneratedAt: '2026-01-01T10:00:00'
+ });
+ const b = thumbnailUrl({
+ id: 'x',
+ thumbnailKey: 'thumbnails/x.jpg',
+ thumbnailGeneratedAt: '2026-01-01T11:00:00'
+ });
+ expect(a).not.toBe(b);
+ });
+});
diff --git a/frontend/src/lib/thumbnails.ts b/frontend/src/lib/thumbnails.ts
new file mode 100644
index 00000000..47a0a606
--- /dev/null
+++ b/frontend/src/lib/thumbnails.ts
@@ -0,0 +1,18 @@
+type ThumbnailDoc = {
+ id: string;
+ thumbnailKey?: string;
+ thumbnailGeneratedAt?: string;
+};
+
+/**
+ * Builds the URL for a document thumbnail image, or returns null when the document
+ * has no thumbnail yet. When `thumbnailGeneratedAt` is present it is appended as a
+ * `?v=…` query param so the browser / proxy cache is invalidated whenever the file
+ * is replaced (the backend regenerates thumbnails at the same S3 key on replace).
+ */
+export function thumbnailUrl(doc: ThumbnailDoc): string | null {
+ if (!doc.thumbnailKey) return null;
+ const base = `/api/documents/${doc.id}/thumbnail`;
+ if (!doc.thumbnailGeneratedAt) return base;
+ return `${base}?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`;
+}
diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte
index 2bc02c83..aa734abf 100644
--- a/frontend/src/routes/admin/system/+page.svelte
+++ b/frontend/src/routes/admin/system/+page.svelte
@@ -14,8 +14,20 @@ type ImportStatus = {
startedAt: string | null;
};
+type ThumbnailStatus = {
+ state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
+ message: string;
+ total: number;
+ processed: number;
+ skipped: number;
+ failed: number;
+ startedAt: string | null;
+};
+
let importStatus: ImportStatus | null = $state(null);
+let thumbnailStatus: ThumbnailStatus | null = $state(null);
let pollInterval: ReturnType
| null = null;
+let thumbnailPollInterval: ReturnType | null = null;
function startPolling() {
if (pollInterval) return;
@@ -29,6 +41,18 @@ function stopPolling() {
}
}
+function startThumbnailPolling() {
+ if (thumbnailPollInterval) return;
+ thumbnailPollInterval = setInterval(fetchThumbnailStatus, 2000);
+}
+
+function stopThumbnailPolling() {
+ if (thumbnailPollInterval) {
+ clearInterval(thumbnailPollInterval);
+ thumbnailPollInterval = null;
+ }
+}
+
async function fetchImportStatus() {
const res = await fetch('/api/admin/import-status');
if (res.ok) {
@@ -51,11 +75,37 @@ async function triggerImport() {
}
}
+async function fetchThumbnailStatus() {
+ const res = await fetch('/api/admin/thumbnail-status');
+ if (res.ok) {
+ thumbnailStatus = await res.json();
+ if (thumbnailStatus!.state === 'RUNNING') {
+ startThumbnailPolling();
+ } else {
+ stopThumbnailPolling();
+ }
+ }
+}
+
+async function triggerThumbnails() {
+ const res = await fetch('/api/admin/generate-thumbnails', { method: 'POST' });
+ if (res.ok) {
+ thumbnailStatus = await res.json();
+ if (thumbnailStatus!.state === 'RUNNING') {
+ startThumbnailPolling();
+ }
+ }
+}
+
$effect(() => {
fetchImportStatus();
+ fetchThumbnailStatus();
});
-onDestroy(() => stopPolling());
+onDestroy(() => {
+ stopPolling();
+ stopThumbnailPolling();
+});
async function backfillVersions() {
backfillLoading = true;
@@ -168,5 +218,62 @@ async function backfillFileHashes() {
{/if}
+
+ {m.admin_system_thumbnails_heading()}
+
+
{m.admin_system_thumbnails_description()}
+
+ {#if thumbnailStatus?.state === 'RUNNING'}
+
+ {m.admin_system_thumbnails_status_running()}
+ {#if thumbnailStatus.total > 0}
+
+ ({thumbnailStatus.processed + thumbnailStatus.skipped + thumbnailStatus.failed} /
+ {thumbnailStatus.total})
+
+ {/if}
+
+ {:else if thumbnailStatus?.state === 'DONE'}
+
+ {m.admin_system_thumbnails_status_done({
+ processed: thumbnailStatus.processed,
+ skipped: thumbnailStatus.skipped,
+ failed: thumbnailStatus.failed
+ })}
+
+
+ {:else if thumbnailStatus?.state === 'FAILED'}
+
+ {m.admin_system_thumbnails_status_failed({ message: thumbnailStatus.message })}
+
+
+ {:else}
+
+ {/if}
+