diff --git a/frontend/src/lib/hooks/__tests__/useFileLoader.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useFileLoader.svelte.test.ts new file mode 100644 index 00000000..4dc4bec3 --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useFileLoader.svelte.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createFileLoader } from '../useFileLoader.svelte'; + +const FAKE_URL = 'blob:fake-url'; + +function setupFetch(ok: boolean, body?: Blob) { + const blob = body ?? new Blob(['fake'], { type: 'application/pdf' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok, + blob: vi.fn().mockResolvedValue(blob) + }) + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('createFileLoader', () => { + it('sets fileUrl after a successful fetch', async () => { + vi.stubGlobal('URL', { + createObjectURL: vi.fn().mockReturnValue(FAKE_URL), + revokeObjectURL: vi.fn() + }); + setupFetch(true); + + const loader = createFileLoader(); + await loader.loadFile('/api/documents/1/file'); + + expect(loader.fileUrl).toBe(FAKE_URL); + expect(loader.isLoading).toBe(false); + expect(loader.fileError).toBe(''); + }); + + it('sets fileError on a failed fetch (non-ok response)', async () => { + vi.stubGlobal('URL', { + createObjectURL: vi.fn(), + revokeObjectURL: vi.fn() + }); + setupFetch(false); + + const loader = createFileLoader(); + await loader.loadFile('/api/documents/1/file'); + + expect(loader.fileUrl).toBe(''); + expect(loader.fileError).not.toBe(''); + expect(loader.isLoading).toBe(false); + }); + + it('revokes the previous URL before creating a new one', async () => { + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { + createObjectURL: vi.fn().mockReturnValue(FAKE_URL), + revokeObjectURL + }); + setupFetch(true); + + const loader = createFileLoader(); + await loader.loadFile('/api/documents/1/file'); + // First load: no previous URL to revoke + expect(revokeObjectURL).not.toHaveBeenCalled(); + + await loader.loadFile('/api/documents/2/file'); + // Second load: previous URL should be revoked + expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL); + }); + + it('revokes the URL on destroy', async () => { + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { + createObjectURL: vi.fn().mockReturnValue(FAKE_URL), + revokeObjectURL + }); + setupFetch(true); + + const loader = createFileLoader(); + await loader.loadFile('/api/documents/1/file'); + loader.destroy(); + + expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL); + }); + + it('does not revoke when no URL has been set', () => { + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { + createObjectURL: vi.fn(), + revokeObjectURL + }); + + const loader = createFileLoader(); + loader.destroy(); + + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/hooks/useFileLoader.svelte.ts b/frontend/src/lib/hooks/useFileLoader.svelte.ts new file mode 100644 index 00000000..a2b7356e --- /dev/null +++ b/frontend/src/lib/hooks/useFileLoader.svelte.ts @@ -0,0 +1,41 @@ +export function createFileLoader() { + let fileUrl = $state(''); + let isLoading = $state(false); + let fileError = $state(''); + + async function loadFile(url: string): Promise { + isLoading = true; + fileError = ''; + if (fileUrl) URL.revokeObjectURL(fileUrl); + fileUrl = ''; + + try { + const response = await fetch(url); + if (!response.ok) throw new Error('Failed to load file'); + const blob = await response.blob(); + fileUrl = URL.createObjectURL(blob); + } catch { + fileError = 'Vorschau konnte nicht geladen werden.'; + } finally { + isLoading = false; + } + } + + function destroy(): void { + if (fileUrl) URL.revokeObjectURL(fileUrl); + } + + return { + get fileUrl() { + return fileUrl; + }, + get isLoading() { + return isLoading; + }, + get fileError() { + return fileError; + }, + loadFile, + destroy + }; +} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 4df5a87b..0d26c275 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -9,6 +9,7 @@ import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.s import type { TranscriptionBlockData } from '$lib/types'; import { getErrorMessage } from '$lib/errors'; import { translateOcrProgress } from '$lib/ocr/translateOcrProgress'; +import { createFileLoader } from '$lib/hooks/useFileLoader.svelte'; let { data } = $props(); @@ -18,43 +19,15 @@ const currentUserId = $derived((data.user?.id as string | undefined) ?? null); // ── File loading ────────────────────────────────────────────────────────────── -let fileUrl = $state(''); -let isLoading = $state(false); -let fileError = $state(''); +const fileLoader = createFileLoader(); $effect(() => { if (doc?.id && doc?.filePath) { - loadFile(doc.id); + fileLoader.loadFile(`/api/documents/${doc.id}/file`); } }); -async function loadFile(id: string) { - isLoading = true; - fileError = ''; - if (fileUrl) URL.revokeObjectURL(fileUrl); - fileUrl = ''; - - try { - const response = await fetch(`/api/documents/${id}/file`); - - if (!response.ok) { - if (response.status === 401) throw new Error('Nicht eingeloggt'); - throw new Error('Fehler beim Laden der Datei'); - } - - const blob = await response.blob(); - fileUrl = URL.createObjectURL(blob); - } catch (e) { - console.error(e); - fileError = 'Vorschau konnte nicht geladen werden.'; - } finally { - isLoading = false; - } -} - -onDestroy(() => { - if (fileUrl) URL.revokeObjectURL(fileUrl); -}); +onDestroy(() => fileLoader.destroy()); // ── Mode state ─────────────────────────────────────────────────────────────── @@ -350,7 +323,7 @@ onMount(() => { @@ -362,9 +335,9 @@ onMount(() => { > import { enhance } from '$app/forms'; import { onMount, onDestroy, untrack } from 'svelte'; +import { createFileLoader } from '$lib/hooks/useFileLoader.svelte'; import { m } from '$lib/paraglide/messages.js'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte'; @@ -11,9 +12,7 @@ let { data, form } = $props(); const doc = $derived(data.document); // File preview state -let fileUrl = $state(''); -let fileError = $state(''); -let isLoading = $state(false); +const fileLoader = createFileLoader(); let navHeight = $state(0); onMount(() => { @@ -27,30 +26,11 @@ let activeAnnotationPage = $state(null); $effect(() => { if (doc?.id && doc?.filePath) { - loadFile(doc.id); + fileLoader.loadFile(`/api/documents/${doc.id}/file`); } }); -async function loadFile(id: string) { - isLoading = true; - fileError = ''; - if (fileUrl) URL.revokeObjectURL(fileUrl); - fileUrl = ''; - try { - const response = await fetch(`/api/documents/${id}/file`); - if (!response.ok) throw new Error('Fehler'); - const blob = await response.blob(); - fileUrl = URL.createObjectURL(blob); - } catch { - fileError = m.doc_file_error_preview(); - } finally { - isLoading = false; - } -} - -onDestroy(() => { - if (fileUrl) URL.revokeObjectURL(fileUrl); -}); +onDestroy(() => fileLoader.destroy()); // Form state let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? [])); @@ -93,9 +73,9 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));