Compare commits

..

6 Commits

Author SHA1 Message Date
Marcel
ca5726e7c3 fix(frontend): zoom buttons now re-render immediately (#39)
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 4m42s
CI / Backend Unit Tests (pull_request) Successful in 2m18s
CI / E2E Tests (pull_request) Failing after 13m15s
CI / Unit & Component Tests (push) Successful in 2m20s
CI / Backend Unit Tests (push) Successful in 2m11s
CI / E2E Tests (push) Failing after 13m25s
scale was only read inside the async renderPage function, so Svelte 5
never tracked it as a reactive dependency of the effect. Reading scale
synchronously in the effect condition registers it as a dependency and
triggers a re-render on every zoom change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:45:25 +01:00
Marcel
0ef81e20f6 devops: add rebuild-frontend.sh script
Some checks are pending
CI / Unit & Component Tests (push) Successful in 2m36s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Has started running
Stops the container, removes the stale node_modules volume, and
rebuilds the image. Run this after adding or updating npm dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:39:53 +01:00
Marcel
1ad8fffd1b fix(frontend): load pdfjs-dist dynamically to avoid SSR crash (#39)
Static import of pdfjs-dist fails during SSR because DOMMatrix and
other browser globals are unavailable in Node.js. Move the import into
onMount so it only ever executes in the browser. A plain pdfjsLib
variable holds the module; a $state boolean pdfjsReady triggers the
load-document effect once the library is available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:39:40 +01:00
Marcel
5fb6a1eec0 feat(frontend): replace iframe with PDF.js viewer (#39)
- Install pdfjs-dist v5 and add optimizeDeps pre-bundle config
- New PdfViewer.svelte component: renders each page on a <canvas> with
  correct device-pixel-ratio scaling, overlays a text layer (enables
  text selection; foundation for annotations in #40), prev/next
  navigation, zoom controls, and lazy page rendering (only current ±1
  pre-fetched — avoids freezing on multi-page documents)
- Replace the <iframe> in documents/[id]/+page.svelte with PdfViewer;
  image attachments continue to use <img>; detection now uses
  doc.contentType instead of filename extension
- Unit tests for navigation controls and page counter (pdfjs mocked)
- E2E tests: PDF renders as canvas (not iframe), nav controls visible,
  image fallback stays as <img>; minimal.pdf fixture for upload tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:10:02 +01:00
Marcel
4f69457a68 fix(dev): inject Authorization header from cookie in Vite dev proxy
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m13s
CI / Backend Unit Tests (pull_request) Successful in 2m9s
CI / E2E Tests (pull_request) Successful in 20m15s
CI / Unit & Component Tests (push) Successful in 2m4s
CI / Backend Unit Tests (push) Successful in 2m8s
CI / E2E Tests (push) Failing after 23s
Browser-side fetch('/api/...') calls bypass SvelteKit's handleFetch hook
(which adds the Authorization header from the auth_token cookie for SSR).
As a result, client-side API calls in the dev server always got a 401.

Add a proxy configure hook that extracts the auth_token cookie from incoming
requests and sets it as the Authorization header before forwarding to the
backend. This makes browser-side fetches (history panel, file preview, etc.)
work correctly in dev mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:05:20 +01:00
Marcel
62f62a89a1 fix(e2e): wait for hydration on document detail page in history tests
Some checks failed
CI / Backend Unit Tests (pull_request) Waiting to run
CI / E2E Tests (pull_request) Waiting to run
CI / Unit & Component Tests (push) Successful in 2m11s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / E2E Tests (push) Failing after 28m46s
All three history tests navigated to the doc page but didn't wait for
SvelteKit hydration, so the toggle onclick wasn't registered yet. Also
wait for versions to load (API call) before asserting on version items
or the compare button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:03:37 +01:00
11 changed files with 850 additions and 27 deletions

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import path from 'path';
/**
* Document management E2E tests.
@@ -142,3 +143,78 @@ test.describe('Document edit', () => {
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
});
});
// ─── PDF Viewer ───────────────────────────────────────────────────────────────
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
test.describe('PDF viewer', () => {
let pdfDocHref: string;
test.beforeAll(async ({ browser }) => {
// Create a document and upload the PDF fixture so later tests have a
// real file attached. Runs once for the whole describe block.
const ctx = await browser.newContext();
const p = await ctx.newPage();
await p.goto('/documents/new');
await p.waitForSelector('[data-hydrated]');
await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
// Upload the PDF on the edit page
const href = p.url().replace(/\/$/, '');
pdfDocHref = href;
await p.goto(`${href}/edit`);
await p.waitForSelector('[data-hydrated]');
await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
await ctx.close();
});
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
page
}) => {
await page.goto(pdfDocHref);
await page.waitForSelector('[data-hydrated]');
// There must be NO iframe — we replaced it with PDF.js canvas rendering.
await expect(page.locator('iframe')).not.toBeAttached();
// At least one canvas element must be visible (one per rendered page).
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
});
test('page navigation controls are visible', async ({ page }) => {
await page.goto(pdfDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
});
test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
// The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
// Navigate to it and confirm an <img> is used (no canvas, no iframe).
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.goto('/?q=Urlaubspostkarte');
const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
const href = await link.getAttribute('href');
await page.goto(href!);
await page.waitForSelector('[data-hydrated]');
// No canvas — this is an image document
await expect(page.locator('canvas')).not.toBeAttached();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
});
});

View File

@@ -0,0 +1,21 @@
%PDF-1.4
1 0 obj
<</Type/Catalog/Pages 2 0 R>>
endobj
2 0 obj
<</Type/Pages/Kids[3 0 R]/Count 1>>
endobj
3 0 obj
<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000054 00000 n
0000000105 00000 n
trailer
<</Size 4/Root 1 0 R>>
startxref
170
%%EOF

View File

@@ -1,26 +1,48 @@
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Document edit history E2E tests.
* Relies on the 'Document creation' and 'Document editing' tests in documents.spec.ts
* having run first (they create and edit "E2E Testbrief (überarbeitet)").
* Assumes auth setup has run.
* Creates its own test document (two versions) in beforeAll so these tests
* are fully independent of any other spec file.
*/
async function openHistoryDoc(page: import('@playwright/test').Page) {
await page.goto('/?q=E2E+Testbrief');
await page.waitForSelector('[data-hydrated]');
const href = await page
.getByRole('link', { name: /E2E Testbrief/ })
.first()
.getAttribute('href');
await page.goto(href!);
await page.waitForSelector('[data-hydrated]');
}
let docPath: string;
test.describe('Document history panel', () => {
test('history section appears after creating and editing a document', async ({ page }) => {
await openHistoryDoc(page);
test.beforeAll(async ({ browser }) => {
// Create a fresh browser context that uses the stored auth session
const context = await browser.newContext({
storageState: path.join(__dirname, '.auth/user.json'),
locale: 'de-DE'
});
const page = await context.newPage();
// 1. Create a new document
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E History Test Dokument');
await page.getByRole('button', { name: /Speichern/i }).click();
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
docPath = new URL(page.url()).pathname;
// 2. Edit the document to create a second version
await page.goto(`${docPath}/edit`);
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
await page.getByRole('button', { name: /Speichern/i }).click();
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
await context.close();
});
test('history section appears and shows two versions', async ({ page }) => {
await page.goto(docPath);
await page.waitForSelector('[data-hydrated]');
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
await expect(historyToggle).toBeVisible();
@@ -34,7 +56,8 @@ test.describe('Document history panel', () => {
});
test('diff view highlights changed field after title edit', async ({ page }) => {
await openHistoryDoc(page);
await page.goto(docPath);
await page.waitForSelector('[data-hydrated]');
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
await historyToggle.click();
@@ -52,7 +75,8 @@ test.describe('Document history panel', () => {
});
test('compare mode lets user compare any two versions', async ({ page }) => {
await openHistoryDoc(page);
await page.goto(docPath);
await page.waitForSelector('[data-hydrated]');
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
await historyToggle.click();
@@ -71,6 +95,10 @@ test.describe('Document history panel', () => {
await expect(selectA).toBeVisible();
await expect(selectB).toBeVisible();
// Select version 1 for A and version 2 for B
await selectA.selectOption({ index: 1 });
await selectB.selectOption({ index: 2 });
await page
.getByRole('button', { name: /Vergleichen/i })
.last()

View File

@@ -9,7 +9,8 @@
"version": "0.0.1",
"dependencies": {
"diff": "^8.0.3",
"openapi-fetch": "^0.13.5"
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -885,6 +886,256 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -3954,6 +4205,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -4129,6 +4387,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.5.207",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.95",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -21,7 +21,8 @@
},
"dependencies": {
"diff": "^8.0.3",
"openapi-fetch": "^0.13.5"
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",

View File

@@ -0,0 +1,305 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
let { url }: { url: string } = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null);
let currentPage = $state(1);
let totalPages = $state(0);
let scale = $state(1.5);
let loading = $state(false);
let error = $state<string | null>(null);
// Canvas and text layer container refs — bound via bind:this, not reactive state
let canvasEl = $state<HTMLCanvasElement | null>(null);
let textLayerEl = $state<HTMLDivElement | null>(null);
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
let renderTask: RenderTask | null = null;
let textLayerInstance: { cancel: () => void } | null = null;
// Holds the dynamically-loaded pdfjs module (browser-only)
// Not $state — we use pdfjsReady as the reactive trigger instead
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false);
onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely
const [lib, { default: workerUrl }] = await Promise.all([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
]);
lib.GlobalWorkerOptions.workerSrc = workerUrl;
pdfjsLib = lib;
pdfjsReady = true;
});
async function loadDocument(src: string) {
if (!pdfjsLib) return;
loading = true;
error = null;
pdfDoc = null;
currentPage = 1;
totalPages = 0;
try {
const loadingTask = pdfjsLib.getDocument(src);
const doc = await loadingTask.promise;
pdfDoc = doc;
totalPages = doc.numPages;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load PDF';
} finally {
loading = false;
}
}
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
// Cancel any in-flight render
if (renderTask) {
renderTask.cancel();
renderTask = null;
}
if (textLayerInstance) {
textLayerInstance.cancel();
textLayerInstance = null;
}
let page: PDFPageProxy;
try {
page = await doc.getPage(pageNum);
} catch {
return;
}
const dpr = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = canvasEl;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const task = page.render({ canvas, canvasContext: ctx, viewport });
renderTask = task;
try {
await task.promise;
} catch (e: unknown) {
if (
typeof e === 'object' &&
e !== null &&
'name' in e &&
(e as { name: string }).name === 'RenderingCancelledException'
)
return;
return;
}
renderTask = null;
// Text layer
const textDiv = textLayerEl;
textDiv.innerHTML = '';
textDiv.style.width = `${viewport.width / dpr}px`;
textDiv.style.height = `${viewport.height / dpr}px`;
const tl = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: textDiv,
viewport
});
textLayerInstance = tl;
try {
await tl.render();
} catch {
// cancelled
}
}
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
for (const n of neighbors) {
try {
await doc.getPage(n);
} catch {
// ignore
}
}
}
$effect(() => {
if (pdfjsReady && url) {
loadDocument(url);
}
});
$effect(() => {
// Read scale synchronously so Svelte tracks it as a dependency.
// Without this, zoom changes don't re-trigger the effect because
// scale is only read inside the async renderPage call.
if (pdfDoc && currentPage && scale > 0) {
renderPage(pdfDoc, currentPage).then(() => {
if (pdfDoc) prerender(pdfDoc, currentPage);
});
}
});
function prevPage() {
if (currentPage > 1) currentPage -= 1;
}
function nextPage() {
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
}
function zoomIn() {
scale += 0.25;
}
function zoomOut() {
if (scale > 0.5) scale -= 0.25;
}
</script>
{#if !url}
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
<p class="font-sans text-sm">Keine Datei vorhanden</p>
</div>
{:else if error}
<div
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
>
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
>
Direkt öffnen
</a>
</div>
{:else}
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
<!-- Controls -->
<div
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
>
<!-- Page navigation -->
<div class="flex items-center gap-2">
<button
onclick={prevPage}
disabled={currentPage <= 1}
aria-label="Zurück"
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
{#if totalPages > 0}
<span class="font-sans text-xs text-gray-300 tabular-nums">
{currentPage} / {totalPages}
</span>
{/if}
<button
onclick={nextPage}
disabled={!pdfDoc || currentPage >= totalPages}
aria-label="Weiter"
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Zoom controls -->
<div class="flex items-center gap-1">
<button
onclick={zoomOut}
aria-label="Verkleinern"
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" /><path
stroke-linecap="round"
d="M21 21l-4.35-4.35M8 11h6"
/>
</svg>
</button>
<button
onclick={zoomIn}
aria-label="Vergrößern"
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" /><path
stroke-linecap="round"
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
/>
</svg>
</button>
</div>
</div>
<!-- PDF canvas area -->
<div class="relative flex-1 overflow-auto">
{#if loading}
<div class="flex h-full items-center justify-center">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
></div>
</div>
{:else}
<div class="flex min-h-full items-start justify-center p-4">
<div
class="pdf-page relative shadow-xl"
data-page-number={currentPage}
style="position: relative"
>
<canvas bind:this={canvasEl}></canvas>
<div
bind:this={textLayerEl}
class="textLayer"
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
></div>
</div>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,53 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
// a real browser PDF engine. The interesting behaviour under test here is the
// component's own UI logic (controls, page counter), not pdfjs internals.
vi.mock('pdfjs-dist', () => {
function TextLayerMock() {}
TextLayerMock.prototype.render = () => Promise.resolve();
TextLayerMock.prototype.cancel = () => {};
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
})
})
}),
TextLayer: TextLayerMock
};
});
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
import PdfViewer from './PdfViewer.svelte';
afterEach(cleanup);
describe('PdfViewer', () => {
it('shows previous and next page navigation buttons', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
});
it('shows zoom controls', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
});
it('displays the page counter once the PDF has loaded', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
// Mock resolves synchronously, so "1 / 2" should appear quickly
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
});
});

View File

@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { diffWords } from 'diff';
import ExpandableText from '$lib/components/ExpandableText.svelte';
import PdfViewer from '$lib/components/PdfViewer.svelte';
let { data } = $props();
@@ -873,16 +874,12 @@ function versionLabel(v: VersionSummary, index: number): string {
</div>
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
</div>
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
<iframe
src="{fileUrl}#zoom=page-width"
title={m.doc_preview_iframe_title()}
class="h-full w-full border-none bg-white"
></iframe>
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
<PdfViewer url={fileUrl} />
{:else if fileUrl}
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
<img
src="{fileUrl}#zoom=page-width"
src={fileUrl}
alt={m.doc_image_alt()}
class="max-h-full max-w-full object-contain shadow-2xl"
/>

View File

@@ -6,6 +6,9 @@ import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
optimizeDeps: {
include: ['pdfjs-dist']
},
server: {
host: '0.0.0.0', // Erlaubt Zugriff von außen
port: 5173, // Standard SvelteKit Port
@@ -13,7 +16,19 @@ export default defineConfig({
proxy: {
'/api': {
target: process.env.API_PROXY_TARGET || 'http://localhost:8080',
changeOrigin: true
changeOrigin: true,
// Inject Authorization header from the auth_token cookie so that
// browser-side fetch('/api/...') calls work the same as SSR fetches
// (which go through handleFetch in hooks.server.ts).
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
const cookies = req.headers.cookie ?? '';
const match = cookies.match(/auth_token=([^;]+)/);
if (match) {
proxyReq.setHeader('Authorization', decodeURIComponent(match[1]));
}
});
}
}
}
},

View File

@@ -207,6 +207,28 @@
resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz"
integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==
"@napi-rs/canvas-linux-x64-gnu@0.1.97":
version "0.1.97"
resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz"
integrity sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==
"@napi-rs/canvas@^0.1.95":
version "0.1.97"
resolved "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz"
integrity sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==
optionalDependencies:
"@napi-rs/canvas-android-arm64" "0.1.97"
"@napi-rs/canvas-darwin-arm64" "0.1.97"
"@napi-rs/canvas-darwin-x64" "0.1.97"
"@napi-rs/canvas-linux-arm-gnueabihf" "0.1.97"
"@napi-rs/canvas-linux-arm64-gnu" "0.1.97"
"@napi-rs/canvas-linux-arm64-musl" "0.1.97"
"@napi-rs/canvas-linux-riscv64-gnu" "0.1.97"
"@napi-rs/canvas-linux-x64-gnu" "0.1.97"
"@napi-rs/canvas-linux-x64-musl" "0.1.97"
"@napi-rs/canvas-win32-arm64-msvc" "0.1.97"
"@napi-rs/canvas-win32-x64-msvc" "0.1.97"
"@playwright/test@^1.58.2":
version "1.58.2"
resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
@@ -1462,6 +1484,11 @@ natural-compare@^1.4.0:
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
node-readable-to-web-readable-stream@^0.4.2:
version "0.4.2"
resolved "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz"
integrity sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==
obug@^2.1.0, obug@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
@@ -1553,6 +1580,14 @@ pathe@^2.0.3:
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
pdfjs-dist@^5.5.207:
version "5.5.207"
resolved "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz"
integrity sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==
optionalDependencies:
"@napi-rs/canvas" "^0.1.95"
node-readable-to-web-readable-stream "^0.4.2"
picocolors@^1.0.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"

21
scripts/rebuild-frontend.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Rebuilds the frontend Docker container and refreshes the node_modules volume.
# Run this after adding or updating npm dependencies.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Stopping frontend container..."
docker compose stop frontend
echo "Removing frontend container..."
docker compose rm -f frontend
echo "Removing stale node_modules volume..."
docker volume rm familienarchiv_frontend_node_modules 2>/dev/null || true
echo "Rebuilding image and starting container..."
docker compose up -d --build frontend
echo "Done. Tailing logs (Ctrl+C to exit)..."
docker compose logs -f frontend