Features/pwa #3
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# PWA / Offline-Fähigkeit (plan/pwa-plan.md)
|
||||
|
||||
- [x] Add `@vite-pwa/sveltekit` dev dependency (pnpm, plus explicit `vite-plugin-pwa`/`workbox-window` for pnpm strict resolution)
|
||||
- [x] Generate PWA icons (192 / 512 / maskable / apple-touch) from `static/favicon.svg`
|
||||
- [x] Configure `SvelteKitPWA` in `vite.config.ts` (manifest, workbox glob patterns incl. pdfjs `.mjs` worker, navigateFallback)
|
||||
- [x] Enable prerendering so route documents are precached (`src/routes/+layout.ts`)
|
||||
- [x] Add PWA client type references (`src/app.d.ts`)
|
||||
- [x] Update toast component + `virtual:pwa-info` manifest link in root layout
|
||||
- [x] Add `theme-color` meta + apple-touch-icon
|
||||
- [x] Production-build verification: SW registers, offline document/tool navigation, offline ZIP + merge chain, update toast (defer + activate) — `pnpm test:pwa`
|
||||
- [x] Run `pnpm check` (0 errors), `pnpm build`
|
||||
- [x] Update `agents.md`
|
||||
- [x] `pnpm lint`: 19 formatting failures fixed via `pnpm format` (shadcn-generated
|
||||
`src/lib/components/ui/` and `src/lib/components/icons/`, plus `AttachmentPreview.svelte`;
|
||||
pure reformatting, no logic changes)
|
||||
- [ ] `datenschutz` wording: draft added to section 4 (“Offline-Nutzung als App (Progressive Web
|
||||
App)”) in `src/routes/datenschutz/+page.svelte` — pending user review/approval (D3)
|
||||
@@ -22,6 +22,7 @@ The application also contains German imprint and privacy-policy routes.
|
||||
- fflate for ZIP extraction and image-export ZIP creation
|
||||
- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
|
||||
- `@sveltejs/adapter-node`
|
||||
- PWA/offline support through `@vite-pwa/sveltekit` (Workbox precache, generated web manifest)
|
||||
- pnpm with a committed lockfile
|
||||
- Prettier with Svelte and Tailwind plugins
|
||||
|
||||
@@ -37,6 +38,7 @@ pnpm preview # Preview the production build
|
||||
pnpm check # Run Svelte and TypeScript diagnostics
|
||||
pnpm check:watch # Run diagnostics in watch mode
|
||||
pnpm test:e2e # Run the Playwright toolbox tests in Chromium
|
||||
pnpm test:pwa # PWA offline/update tests against the production build (requires pnpm build)
|
||||
pnpm lint # Check repository formatting with Prettier
|
||||
pnpm format # Format the repository with Prettier
|
||||
```
|
||||
@@ -75,6 +77,7 @@ src/
|
||||
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
|
||||
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
|
||||
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
|
||||
│ │ ├── PwaUpdatePrompt.svelte # Service-worker registration and update/offline toast
|
||||
│ │ ├── RasterizationWarning.svelte
|
||||
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone
|
||||
│ │ ├── icons/ # Project-specific SVG components
|
||||
@@ -92,6 +95,7 @@ src/
|
||||
│ └── zip-processing.ts # Archive model, transforms, and PDF export
|
||||
└── routes/
|
||||
├── +layout.svelte # Shared logo header and branded footer
|
||||
├── +layout.ts # `prerender = true` for all routes (PWA offline documents)
|
||||
├── +page.svelte # Toolbox hub and global beA ZIP drop target
|
||||
├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
|
||||
├── tools/
|
||||
@@ -100,15 +104,42 @@ src/
|
||||
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
|
||||
├── datenschutz/+page.svelte # Privacy policy
|
||||
└── impressum/+page.svelte # Imprint
|
||||
static/ # Public logo, footer art, and robots.txt
|
||||
static/ # Public logo, footer art, PWA icons, and robots.txt
|
||||
components.json # shadcn-svelte registry configuration
|
||||
Dockerfile # Node adapter production image
|
||||
playwright.pwa.config.ts # Playwright config for `pnpm test:pwa`
|
||||
plan/ # Design and implementation notes
|
||||
```
|
||||
|
||||
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
|
||||
committed.
|
||||
|
||||
## PWA / offline mode
|
||||
|
||||
- `SvelteKitPWA` is configured in `vite.config.ts` with `registerType: 'prompt'`: a waiting service
|
||||
worker never activates by itself. `PwaUpdatePrompt.svelte` registers the worker in the browser and
|
||||
shows a toast (“Neue Version verfügbar” with “Neu laden”/“Später”, plus a one-time hint when the
|
||||
app is ready offline). Never force a reload — an in-progress archive must survive a declined
|
||||
update.
|
||||
- All routes are prerendered (`src/routes/+layout.ts`), so route documents land in the precache and
|
||||
offline navigation works for every page, including a direct deep link. `navigateFallback: '/'`
|
||||
covers unknown paths.
|
||||
- Precache contents come from `workbox.globPatterns`: the plugin's client/prerendered defaults plus
|
||||
`mjs` (the pdfjs worker chunk, required for offline rendering) and the local fonts; the unused
|
||||
`static/Nunito/**` font drop is excluded via `globIgnores`. Precache size is ~4.5 MB.
|
||||
- PWA icons in `static/icons/` (192/512, maskable, apple-touch) were generated once from
|
||||
`static/favicon.svg`; regenerate them with ImageMagick/rsvg if the logo changes. The manifest link
|
||||
is injected client-side from `virtual:pwa-info` in the root layout, `theme-color` and the
|
||||
apple-touch-icon live in `app.html`.
|
||||
- `devOptions.enabled` stays `false`: `pnpm dev` never registers a service worker, so development
|
||||
caches stay clean. Verify PWA behaviour only against a production build (`pnpm build` and
|
||||
`pnpm preview`, or `pnpm test:pwa`, which also covers the offline processing chain and the update
|
||||
toast).
|
||||
- Simulating a deployment in tests requires restarting `node build/index.js`: adapter-node's static
|
||||
handler (sirv, non-dev mode) walks the build directory once at startup and caches the ETags, so
|
||||
swapped files are invisible to a running process. Browsers revalidate `/sw.js` via its ETag on
|
||||
navigation and `registration.update()`, so the normal container restart on deploy is sufficient.
|
||||
|
||||
## Application flow and state
|
||||
|
||||
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test:pwa": "playwright test -c playwright.pwa.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/node": "^26.3.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"bits-ui": "2.18.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -39,7 +41,9 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.9.1",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Plan: PWA / Offline-Fähigkeit
|
||||
|
||||
## Goal
|
||||
|
||||
The app already processes everything locally — but the browser still has to fetch the app shell
|
||||
from the server on every load. Making **beA-Edit an installable, offline-capable PWA** reinforces
|
||||
the privacy story ("works without internet after first load"), enables home-screen/desktop
|
||||
installation, and is essentially free given there are no network calls to cache-around.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (defaults marked ✅)
|
||||
|
||||
### D1 — Implementation approach
|
||||
|
||||
- ✅ **A. `@vite-pwa/sveltekit`** (new **dev** dependency). Mature integration: precaching of the
|
||||
built assets via Workbox, generated manifest, virtual `RegisterSW` module, works with
|
||||
`adapter-node` builds. The dependency is justified (agents.md allows deps when the task cannot
|
||||
reasonably be done without) — hand-rolling a correct service worker + update flow is
|
||||
error-prone.
|
||||
- B. Hand-written minimal `sw.js` in `static/` + manual manifest. Zero deps but custom update
|
||||
logic, manual asset hashing, more long-term maintenance.
|
||||
|
||||
### D2 — Update UX
|
||||
|
||||
- ✅ **A. Auto-update with a toast.** New versions activate on next navigation; a small toast
|
||||
"Neue Version verfügbar – Seite neu laden" appears when an update is waiting (Workbox
|
||||
`onNeedRefresh`/`onOfflineReady` hooks). Never force-reload mid-work (users may have unsaved
|
||||
archive state).
|
||||
- B. Silent auto-reload. Rejected: could interrupt an in-progress merge.
|
||||
|
||||
### D3 — Legal pages
|
||||
|
||||
- ✅ **Review required, not auto-edited.** A service worker + install prompt is client-side
|
||||
storage/behavior; `datenschutz/+page.svelte` should likely gain a sentence. Flag for the user;
|
||||
do not rewrite legal copy unilaterally (project constraint).
|
||||
|
||||
---
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Vite config
|
||||
|
||||
```ts
|
||||
// vite.config.ts
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
tailwindcss(),
|
||||
SvelteKitPWA({
|
||||
registerType: 'prompt',
|
||||
manifest: {
|
||||
name: 'beA-Edit',
|
||||
short_name: 'beA-Edit',
|
||||
description: 'PDF-Werkzeuge für die beA-Kanzlei – 100 % lokal im Browser.',
|
||||
lang: 'de',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#f2f5f8', // align with theme background token
|
||||
theme_color: '#…', // align with primary token
|
||||
icons: [
|
||||
/* 192 + 512 + maskable, derived from static logo */
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
|
||||
navigateFallback: '/' // SPA-style offline fallback for app routes
|
||||
},
|
||||
devOptions: { enabled: false }
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- pdfjs worker chunk (`pdf.worker.mjs?url`) is part of the built assets → precached by glob.
|
||||
- Legal routes (`/impressum`, `/datenschutz`) precache like any route; offline navigation works
|
||||
via the fallback.
|
||||
- No runtime caching strategies needed: the app makes **no** network requests by design.
|
||||
|
||||
### Registration UI
|
||||
|
||||
- Root `+layout.svelte`: import the virtual `virtual:pwa-register` module (SSR-guarded), wire
|
||||
`onNeedRefresh` → toast state; render a small dismissible toast component (build with existing
|
||||
card/button styling; no new UI dep) with "Neu laden" / "Später".
|
||||
- `onOfflineReady` → optional one-time hint "beA-Edit funktioniert jetzt offline." (keep subtle).
|
||||
- Install prompt: rely on the browser's native install affordance (manifest makes it appear);
|
||||
optionally an "Installieren" hint on the start page later — out of scope for v1.
|
||||
|
||||
### Icons
|
||||
|
||||
- Generate 192/512/maskable PNGs from the existing logo (`static/` logo asset) once, committed
|
||||
to `static/icons/`. Use an offline tool/imagemagick during implementation; no runtime dep.
|
||||
|
||||
### Docker / adapter-node
|
||||
|
||||
- Service worker + manifest are static build output → served by `node build/index.js` without
|
||||
changes. Verify `Cache-Control` headers don't block SW updates (adapter-node defaults are fine;
|
||||
the SW itself must not be cached aggressively — Vite PWA emits the right filenames).
|
||||
|
||||
## Files changed
|
||||
|
||||
- `vite.config.ts` — SvelteKitPWA plugin + manifest.
|
||||
- `package.json` / lockfile — `@vite-pwa/sveltekit` dev dependency (via pnpm only).
|
||||
- `src/routes/+layout.svelte` — registration + update toast.
|
||||
- `static/icons/*` — generated icons; `static/manifest.webmanifest` handled by plugin.
|
||||
- `src/app.html` — `theme-color` meta if plugin doesn't inject.
|
||||
- `agents.md` — document PWA setup + update behavior.
|
||||
- `datenschutz/+page.svelte` — only after explicit user approval (D3).
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. Add dependency, configure plugin + manifest + icons.
|
||||
2. Registration + update toast in root layout.
|
||||
3. Build + `pnpm preview`: verify `/` loads, then devtools → Application → service worker
|
||||
registered; toggle "Offline" → reload → app still works.
|
||||
4. Production image smoke test (Docker build, offline reload).
|
||||
5. Update `agents.md`; flag datenschutz review.
|
||||
|
||||
## Verification
|
||||
|
||||
- Fresh load registers the SW (devtools), `onOfflineReady` path reachable.
|
||||
- Offline reload after precache: start page + all tool routes render; dropping a ZIP still works
|
||||
fully (entire processing chain offline).
|
||||
- New build deployed → toast offers reload; choosing it loads the new version; declining does
|
||||
not interrupt current state.
|
||||
- No console errors on iOS Safari (manifest parsing quirks) — best-effort check.
|
||||
- `pnpm check`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Stale cache confusion** during development: keep `devOptions.enabled: false`; document in
|
||||
agents.md that preview/production test the SW, not dev mode.
|
||||
- **Large precache manifest**: the pdfjs worker + fonts add a few MB to the first precache —
|
||||
acceptable for an installable tool; monitor build output stats.
|
||||
- **SW + SvelteKit hydration edge cases** on first offline visit are known upstream; pin a
|
||||
current stable plugin version and test the exact offline-reload sequence.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Background sync (nothing to sync — no network by design).
|
||||
- App-store packaging.
|
||||
- Custom install button UI beyond the native affordance.
|
||||
@@ -0,0 +1,29 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
// TEMPORARY: verification harness for the PWA offline/update behaviour.
|
||||
// The spec starts the production `node build/index.js` server itself, because a "deployment"
|
||||
// has to restart the process (adapter-node caches build-file stats at startup).
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const chromiumExecutable =
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ??
|
||||
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: 'pwa.spec.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
timeout: 420_000,
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4179',
|
||||
trace: 'retain-on-failure',
|
||||
launchOptions: {
|
||||
executablePath: chromiumExecutable,
|
||||
args: ['--no-sandbox']
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#122f62" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -1,129 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import type {ZipAttachment} from "$lib/zip-processing";
|
||||
import type { ZipAttachment } from '$lib/zip-processing';
|
||||
|
||||
type Props = {
|
||||
attachment: ZipAttachment;
|
||||
class?: string;
|
||||
};
|
||||
type Props = {
|
||||
attachment: ZipAttachment;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { attachment, class: className = "" }: Props = $props();
|
||||
let { attachment, class: className = '' }: Props = $props();
|
||||
|
||||
let imageUrl = $state<string | null>(null);
|
||||
let previewError = $state<string | null>(null);
|
||||
let imageUrl = $state<string | null>(null);
|
||||
let previewError = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
onMount(() => {
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const revokeObjectUrl = () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
};
|
||||
const revokeObjectUrl = () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setBlobImage = (blob: Blob) => {
|
||||
revokeObjectUrl();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
imageUrl = objectUrl;
|
||||
};
|
||||
const setBlobImage = (blob: Blob) => {
|
||||
revokeObjectUrl();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
imageUrl = objectUrl;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const pdfjsLib = await import("pdfjs-dist");
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
(async () => {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
|
||||
GlobalWorkerOptions.workerSrc = await import("pdfjs-dist/build/pdf.worker.mjs?url").then((m) => m.default);
|
||||
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||
(m) => m.default
|
||||
);
|
||||
|
||||
const loadPreview = async () => {
|
||||
previewError = null;
|
||||
imageUrl = null;
|
||||
const loadPreview = async () => {
|
||||
previewError = null;
|
||||
imageUrl = null;
|
||||
|
||||
if (attachment.kind === "image") {
|
||||
const imageBuffer = attachment.data.buffer.slice(
|
||||
attachment.data.byteOffset,
|
||||
attachment.data.byteOffset + attachment.data.byteLength,
|
||||
) as ArrayBuffer;
|
||||
setBlobImage(new Blob([imageBuffer]));
|
||||
return;
|
||||
}
|
||||
if (attachment.kind === 'image') {
|
||||
const imageBuffer = attachment.data.buffer.slice(
|
||||
attachment.data.byteOffset,
|
||||
attachment.data.byteOffset + attachment.data.byteLength
|
||||
) as ArrayBuffer;
|
||||
setBlobImage(new Blob([imageBuffer]));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// create a copy, getDocument seems to manipulate the data -> creating pdfs fails
|
||||
const buffer = new Uint8Array(attachment.data);
|
||||
const loadingTask = getDocument({ data: buffer });
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
const page = await pdfDocument.getPage(1);
|
||||
try {
|
||||
// create a copy, getDocument seems to manipulate the data -> creating pdfs fails
|
||||
const buffer = new Uint8Array(attachment.data);
|
||||
const loadingTask = getDocument({ data: buffer });
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
const page = await pdfDocument.getPage(1);
|
||||
|
||||
if (cancelled) {
|
||||
void loadingTask.destroy();
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
void loadingTask.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvasElement = document.createElement("canvas");
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvasElement = document.createElement('canvas');
|
||||
|
||||
canvasElement.width = viewport.width;
|
||||
canvasElement.height = viewport.height;
|
||||
canvasElement.width = viewport.width;
|
||||
canvasElement.height = viewport.height;
|
||||
|
||||
const canvasContext = canvasElement.getContext("2d");
|
||||
const canvasContext = canvasElement.getContext('2d');
|
||||
|
||||
if (!canvasContext) {
|
||||
throw new Error("Could not create thumbnail canvas context.");
|
||||
}
|
||||
if (!canvasContext) {
|
||||
throw new Error('Could not create thumbnail canvas context.');
|
||||
}
|
||||
|
||||
await page.render({ canvas: canvasElement, canvasContext, viewport }).promise;
|
||||
await page.render({ canvas: canvasElement, canvasContext, viewport }).promise;
|
||||
|
||||
if (cancelled) {
|
||||
void loadingTask.destroy();
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
void loadingTask.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvasElement.toBlob((result) => {
|
||||
if (result) {
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvasElement.toBlob((result) => {
|
||||
if (result) {
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error("Could not create thumbnail blob."));
|
||||
}, "image/png");
|
||||
});
|
||||
reject(new Error('Could not create thumbnail blob.'));
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
setBlobImage(blob);
|
||||
setBlobImage(blob);
|
||||
|
||||
await loadingTask.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Failed to render preview for ${attachment.path}`, error);
|
||||
previewError = "PDF-Vorschau konnte nicht geladen werden.";
|
||||
}
|
||||
};
|
||||
await loadingTask.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Failed to render preview for ${attachment.path}`, error);
|
||||
previewError = 'PDF-Vorschau konnte nicht geladen werden.';
|
||||
}
|
||||
};
|
||||
|
||||
void loadPreview();
|
||||
})();
|
||||
void loadPreview();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
revokeObjectUrl();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
revokeObjectUrl();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
{#if previewError}
|
||||
<div class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
|
||||
{previewError}
|
||||
</div>
|
||||
{:else if imageUrl}
|
||||
<img
|
||||
alt={attachment.name}
|
||||
class="block rounded-md border border-primary-100 bg-white object-contain"
|
||||
src={imageUrl}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
|
||||
Vorschau wird erstellt
|
||||
</div>
|
||||
{/if}
|
||||
{#if previewError}
|
||||
<div
|
||||
class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
|
||||
>
|
||||
{previewError}
|
||||
</div>
|
||||
{:else if imageUrl}
|
||||
<img
|
||||
alt={attachment.name}
|
||||
class="block rounded-md border border-primary-100 bg-white object-contain"
|
||||
src={imageUrl}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
|
||||
>
|
||||
Vorschau wird erstellt
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
|
||||
/**
|
||||
* Service-worker registration and the update hint for the PWA.
|
||||
*
|
||||
* Rendered only in the browser: the plugin's virtual modules are no-ops during SSR and in
|
||||
* `pnpm dev` (PWA `devOptions` are disabled), so `pwaInfo` is `undefined` there and nothing is
|
||||
* registered.
|
||||
*
|
||||
* Updates are never applied automatically (registerType `prompt`): a waiting worker is only
|
||||
* activated when the user confirms, so an in-progress archive is never interrupted.
|
||||
*/
|
||||
let needRefresh = $state(false);
|
||||
let offlineReady = $state(false);
|
||||
let updateServiceWorker = $state<(reloadPage?: boolean) => Promise<void>>(async () => {});
|
||||
|
||||
let offlineReadyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (!pwaInfo) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const { registerSW } = await import('virtual:pwa-register');
|
||||
if (cancelled) return;
|
||||
|
||||
updateServiceWorker = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
offlineReady = false;
|
||||
needRefresh = true;
|
||||
},
|
||||
onOfflineReady() {
|
||||
offlineReady = true;
|
||||
offlineReadyTimer = setTimeout(close, 8000);
|
||||
},
|
||||
onRegisterError(error: unknown) {
|
||||
console.error('Service Worker konnte nicht registriert werden.', error);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (offlineReadyTimer) clearTimeout(offlineReadyTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function close() {
|
||||
needRefresh = false;
|
||||
offlineReady = false;
|
||||
if (offlineReadyTimer) {
|
||||
clearTimeout(offlineReadyTimer);
|
||||
offlineReadyTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await updateServiceWorker(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if needRefresh || offlineReady}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-4 bottom-4 z-50 flex flex-col gap-3 rounded-2xl border border-border bg-white p-4 shadow-lg sm:left-auto sm:w-[min(24rem,calc(100vw-2rem))]"
|
||||
>
|
||||
<p class="text-sm font-bold text-primary">
|
||||
{needRefresh
|
||||
? 'Neue Version von beA-Edit verfügbar.'
|
||||
: 'beA-Edit funktioniert jetzt offline.'}
|
||||
</p>
|
||||
{#if needRefresh}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Die neue Version wird geladen, sobald Sie neu laden. Ein laufender Vorgang wird dabei nicht
|
||||
unterbrochen.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-row flex-wrap gap-3">
|
||||
{#if needRefresh}
|
||||
<Button variant="primary" size="standard" onclick={reload}>Neu laden</Button>
|
||||
{/if}
|
||||
<Button variant="bordered" size="standard" onclick={close}>Später</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,7 +2,17 @@
|
||||
</script>
|
||||
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5618 6.94745L14.1001 3.31245C15.0133 2.37424 16.5206 2.37424 17.4338 3.31245L19.0544 4.97737C19.9334 5.88045 19.9334 7.31929 19.0544 8.22237L14.3368 13.069C13.4235 14.0072 11.9162 14.0072 11.003 13.069L10.5537 12.6075" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.8717 16.4126L7.66659 21.76L4.37911 18.3826C3.50007 17.4796 3.50008 16.0407 4.37911 15.1376L9.09675 10.291C10.01 9.35276 11.5173 9.35276 12.4305 10.291L12.8798 10.7525" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M10.5618 6.94745L14.1001 3.31245C15.0133 2.37424 16.5206 2.37424 17.4338 3.31245L19.0544 4.97737C19.9334 5.88045 19.9334 7.31929 19.0544 8.22237L14.3368 13.069C13.4235 14.0072 11.9162 14.0072 11.003 13.069L10.5537 12.6075"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.8717 16.4126L7.66659 21.76L4.37911 18.3826C3.50007 17.4796 3.50008 16.0407 4.37911 15.1376L9.09675 10.291C10.01 9.35276 11.5173 9.35276 12.4305 10.291L12.8798 10.7525"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -2,9 +2,29 @@
|
||||
</script>
|
||||
|
||||
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.6875 19.7575C18.6875 21.0807 17.6148 22.1533 16.2917 22.1533H4.3125V5.99287C4.3125 5.39212 4.54755 4.81522 4.96738 4.38552L7.17097 2.13015C7.60374 1.68721 8.19683 1.4375 8.81609 1.4375H16.3875C17.6578 1.4375 18.6875 2.46725 18.6875 3.7375V19.7575Z" stroke="currentColor" stroke-width="1.86875"/>
|
||||
<path d="M7.06787 12.5889H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
|
||||
<path d="M7.06787 8.71875H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
|
||||
<path d="M7.06787 16.464H12.8179" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M18.6875 19.7575C18.6875 21.0807 17.6148 22.1533 16.2917 22.1533H4.3125V5.99287C4.3125 5.39212 4.54755 4.81522 4.96738 4.38552L7.17097 2.13015C7.60374 1.68721 8.19683 1.4375 8.81609 1.4375H16.3875C17.6578 1.4375 18.6875 2.46725 18.6875 3.7375V19.7575Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.86875"
|
||||
/>
|
||||
<path
|
||||
d="M7.06787 12.5889H15.6929"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.725"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.06787 8.71875H15.6929"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.725"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.06787 16.464H12.8179"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.725"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
</script>
|
||||
|
||||
<svg width="21" height="15" viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13.2188C0 12.5457 0.545653 12 1.21875 12H16.8256C17.339 12 17.6536 12.5629 17.3845 13.0002L16.5 14.4375H1.21875C0.545653 14.4375 0 13.8918 0 13.2188Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 7.21875C0 6.54565 0.545653 6 1.21875 6H18.2738C18.7979 6 19.1106 6.58416 18.8198 7.02027L17.875 8.4375H1.21875C0.545653 8.4375 0 7.89185 0 7.21875Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1.21875C0 0.545653 0.545653 0 1.21875 0H19.721C20.2555 0 20.5658 0.60479 20.2541 1.03898L19.25 2.4375H1.21875C0.545653 2.4375 0 1.89185 0 1.21875Z" fill="currentColor"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 13.2188C0 12.5457 0.545653 12 1.21875 12H16.8256C17.339 12 17.6536 12.5629 17.3845 13.0002L16.5 14.4375H1.21875C0.545653 14.4375 0 13.8918 0 13.2188Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 7.21875C0 6.54565 0.545653 6 1.21875 6H18.2738C18.7979 6 19.1106 6.58416 18.8198 7.02027L17.875 8.4375H1.21875C0.545653 8.4375 0 7.89185 0 7.21875Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 1.21875C0 0.545653 0.545653 0 1.21875 0H19.721C20.2555 0 20.5658 0.60479 20.2541 1.03898L19.25 2.4375H1.21875C0.545653 2.4375 0 1.89185 0 1.21875Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
</script>
|
||||
|
||||
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.4129 7.29267L14.1203 1C14.1203 1 11.8437 8.76535 4.14746 10.9744L11.7731 18.6C13.9805 10.9022 21.7475 8.62562 21.7475 8.62562L15.4548 2.33295" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M7.28204 14.2833L8.8 15.8656L1.51796 23.0833L1.36219 22.921C0.606308 22.1331 0.622023 20.8845 1.3975 20.1159L7.28204 14.2833Z" fill="currentColor"/>
|
||||
<path
|
||||
d="M20.4129 7.29267L14.1203 1C14.1203 1 11.8437 8.76535 4.14746 10.9744L11.7731 18.6C13.9805 10.9022 21.7475 8.62562 21.7475 8.62562L15.4548 2.33295"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.28204 14.2833L8.8 15.8656L1.51796 23.0833L1.36219 22.921C0.606308 22.1331 0.622023 20.8845 1.3975 20.1159L7.28204 14.2833Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
</script>
|
||||
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9914 12.6763V15.4458V20.4H7.98481H3.6001V16.046V4.27311C3.6001 3.32208 4.49438 2.6246 5.41678 2.85624L11.8863 4.48089C12.5359 4.64402 12.9914 5.228 12.9914 5.89777V12.6763Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M13.6491 8.40002L20.5221 10.2682C21.1583 10.4411 21.5998 11.0186 21.5998 11.6779V12.6424V15.424V18.9392C21.5998 19.746 20.9457 20.4 20.1389 20.4H17.4409H12.2085" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M4.5 8.3H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M4.5 12.1H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M4.5 15.9H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M14 13.1H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M14 16.9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12.9914 12.6763V15.4458V20.4H7.98481H3.6001V16.046V4.27311C3.6001 3.32208 4.49438 2.6246 5.41678 2.85624L11.8863 4.48089C12.5359 4.64402 12.9914 5.228 12.9914 5.89777V12.6763Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.6491 8.40002L20.5221 10.2682C21.1583 10.4411 21.5998 11.0186 21.5998 11.6779V12.6424V15.424V18.9392C21.5998 19.746 20.9457 20.4 20.1389 20.4H17.4409H12.2085"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path d="M4.5 8.3H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M4.5 12.1H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M4.5 15.9H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M14 13.1H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M14 16.9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498" stroke="currentColor" stroke-width="1.95" stroke-linecap="round"/>
|
||||
<circle cx="7.4751" cy="5.47498" r="4.5" stroke="currentColor" stroke-width="1.95"/>
|
||||
<path
|
||||
d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.95"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="7.4751" cy="5.47498" r="4.5" stroke="currentColor" stroke-width="1.95" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 518 B |
@@ -2,11 +2,19 @@
|
||||
</script>
|
||||
|
||||
<svg width="22" height="23" viewBox="0 0 22 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 17.5834C21.5523 17.5834 22 18.0311 22 18.5834V19.5834H1C0.447716 19.5834 0 19.1357 0 18.5834C0 18.0311 0.447715 17.5834 1 17.5834H21Z" fill="#122F62"/>
|
||||
<path d="M21 10.5834C21.5523 10.5834 22 11.0311 22 11.5834V12.5834H1C0.447716 12.5834 0 12.1357 0 11.5834C0 11.0311 0.447715 10.5834 1 10.5834H21Z" fill="#122F62"/>
|
||||
<path d="M21 3.08337C21.5523 3.08337 22 3.53109 22 4.08337V5.08337H1C0.447716 5.08337 0 4.63566 0 4.08337C0 3.53109 0.447715 3.08337 1 3.08337H21Z" fill="#122F62"/>
|
||||
<circle cx="6.5" cy="3.75" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
|
||||
<circle cx="15.5835" cy="11.0834" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
|
||||
<circle cx="6.5" cy="18.4167" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
|
||||
<path
|
||||
d="M21 17.5834C21.5523 17.5834 22 18.0311 22 18.5834V19.5834H1C0.447716 19.5834 0 19.1357 0 18.5834C0 18.0311 0.447715 17.5834 1 17.5834H21Z"
|
||||
fill="#122F62"
|
||||
/>
|
||||
<path
|
||||
d="M21 10.5834C21.5523 10.5834 22 11.0311 22 11.5834V12.5834H1C0.447716 12.5834 0 12.1357 0 11.5834C0 11.0311 0.447715 10.5834 1 10.5834H21Z"
|
||||
fill="#122F62"
|
||||
/>
|
||||
<path
|
||||
d="M21 3.08337C21.5523 3.08337 22 3.53109 22 4.08337V5.08337H1C0.447716 5.08337 0 4.63566 0 4.08337C0 3.53109 0.447715 3.08337 1 3.08337H21Z"
|
||||
fill="#122F62"
|
||||
/>
|
||||
<circle cx="6.5" cy="3.75" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
|
||||
<circle cx="15.5835" cy="11.0834" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
|
||||
<circle cx="6.5" cy="18.4167" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
|
||||
</svg>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<svg width="24" height="23" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z" stroke="currentColor" stroke-width="2.08"/>
|
||||
<circle cx="13.7192" cy="9.90002" r="1.5" fill="currentColor"/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.08"
|
||||
/>
|
||||
<circle cx="13.7192" cy="9.90002" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 563 B |
@@ -2,9 +2,11 @@
|
||||
</script>
|
||||
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 7V19C18 20.1046 17.1046 21 16 21H6V7" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M8 4C8 3.44772 8.44772 3 9 3H15C15.5523 3 16 3.44772 16 4V7H8V4Z" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M3 7.5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18 7V19C18 20.1046 17.1046 21 16 21H6V7" stroke="currentColor" stroke-width="2" />
|
||||
<path
|
||||
d="M8 4C8 3.44772 8.44772 3 9 3H15C15.5523 3 16 3.44772 16 4V7H8V4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
<path d="M3 7.5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
</script>
|
||||
|
||||
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.49023 1H12.6572C13.5748 1 14.375 1.62449 14.5977 2.51465L17.0977 12.5146C17.4132 13.7769 16.4584 15 15.1572 15H1.20508L3.52441 2.63184C3.70178 1.68589 4.5278 1 5.49023 1Z" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="5" y="17" width="8" height="2" rx="1" fill="currentColor"/>
|
||||
<path
|
||||
d="M5.49023 1H12.6572C13.5748 1 14.375 1.62449 14.5977 2.51465L17.0977 12.5146C17.4132 13.7769 16.4584 15 15.1572 15H1.20508L3.52441 2.63184C3.70178 1.68589 4.5278 1 5.49023 1Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<rect x="5" y="17" width="8" height="2" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
@@ -1,89 +1,93 @@
|
||||
<script lang="ts" module>
|
||||
import {ripple} from "$lib/actions/ripple";
|
||||
import {cn, type WithElementRef} from "$lib/utils.js";
|
||||
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements";
|
||||
import {type VariantProps, tv} from "tailwind-variants";
|
||||
import type {Component} from "svelte";
|
||||
import { ripple } from '$lib/actions/ripple';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "relative overflow-hidden font-bold w-fit min-w-28 hover:not-disabled:cursor-pointer disabled:bg-[#E6E7EA] disabled:text-[#0B1F4366] inline-flex items-center justify-center gap-4",
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-primary text-primary-foreground",
|
||||
secondary: "bg-linear-to-r from-secondary to-[#FF6F6D] text-primary-foreground",
|
||||
bordered: "text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]",
|
||||
tertiary: "text-primary hover:bg-[#122F621A]",
|
||||
success: "text-white bg-[#0FAD80]"
|
||||
},
|
||||
size: {
|
||||
standard: "px-6 h-8 rounded-[16px]",
|
||||
large: "px-8 h-12 rounded-[24px]"
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
size: "standard",
|
||||
},
|
||||
});
|
||||
export const buttonVariants = tv({
|
||||
base: 'relative overflow-hidden font-bold w-fit min-w-28 hover:not-disabled:cursor-pointer disabled:bg-[#E6E7EA] disabled:text-[#0B1F4366] inline-flex items-center justify-center gap-4',
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-primary text-primary-foreground',
|
||||
secondary: 'bg-linear-to-r from-secondary to-[#FF6F6D] text-primary-foreground',
|
||||
bordered: 'text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]',
|
||||
tertiary: 'text-primary hover:bg-[#122F621A]',
|
||||
success: 'text-white bg-[#0FAD80]'
|
||||
},
|
||||
size: {
|
||||
standard: 'px-6 h-8 rounded-[16px]',
|
||||
large: 'px-8 h-12 rounded-[24px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'standard'
|
||||
}
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: Component;
|
||||
};
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: Component;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "primary",
|
||||
size = "standard",
|
||||
icon,
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
let {
|
||||
class: className,
|
||||
variant = 'primary',
|
||||
size = 'standard',
|
||||
icon,
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = 'button',
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
use:ripple
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn("", buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if icon}
|
||||
{@const Icon = icon}
|
||||
<Icon/>
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
use:ripple
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn('', buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if icon}
|
||||
{@const Icon = icon}
|
||||
<Icon />
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
use:ripple
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn("inline-flex items-center justify-center gap-4", buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if icon}
|
||||
{@const Icon = icon}
|
||||
<Icon/>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
use:ripple
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(
|
||||
'inline-flex items-center justify-center gap-4',
|
||||
buttonVariants({ variant, size }),
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if icon}
|
||||
{@const Icon = icon}
|
||||
<Icon />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -14,7 +14,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn(
|
||||
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
'cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-content"
|
||||
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
class={cn('px-4 group-data-[size=sm]/card:px-3', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,10 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||
class={cn(
|
||||
'bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -14,7 +14,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
'gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
class={cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: 'default' | 'sm' } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
class={cn(
|
||||
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
{#if pwaInfo}
|
||||
<!-- rendered on the client only: the manifest is part of the production build output -->
|
||||
{@html pwaInfo.webManifest.linkTag}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<div class="flex min-h-dvh flex-col">
|
||||
<header style="height:72px" class="grid shrink-0 place-content-center bg-white shadow">
|
||||
@@ -19,6 +25,8 @@
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<PwaUpdatePrompt />
|
||||
|
||||
<footer
|
||||
class="mt-8.75 flex h-96 min-h-78.5 shrink-0 flex-col justify-end gap-15 px-4 py-8.75 text-white"
|
||||
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Every route is rendered from static markup and processes data purely in the browser, so all
|
||||
// pages are prerendered. That is what makes the PWA work offline: `@vite-pwa/sveltekit` precaches
|
||||
// `prerendered/**/*.{html,json}` (see vite.config.ts), so the service worker can answer document
|
||||
// navigations without a server.
|
||||
export const prerender = true;
|
||||
@@ -402,6 +402,24 @@
|
||||
Darstellung und der Optimierung seiner Website – hierzu müssen die Server-Log-Files
|
||||
erfasst werden.
|
||||
</p>
|
||||
|
||||
<h3>Offline-Nutzung als App (Progressive Web App)</h3>
|
||||
<p>
|
||||
Diese Website ist technisch so aufgebaut, dass sie sich als sogenannte Progressive Web
|
||||
App (PWA) in Ihrem Browser installieren und auch ohne bestehende Internetverbindung
|
||||
nutzen lässt. Zu diesem Zweck speichert Ihr Browser die für den Betrieb der Website
|
||||
benötigten Dateien (z. B. Grafiken und Programmdateien) in einem lokalen Cache.
|
||||
</p>
|
||||
<p>
|
||||
In diesem Cache werden ausschließlich diese Anwendungsdateien abgelegt. Die von Ihnen
|
||||
verarbeiteten Dokumente werden nicht gespeichert, nicht übertragen und verlassen Ihr
|
||||
Gerät nicht: Sämtliche Verarbeitungsschritte erfolgen vollständig lokal in Ihrem
|
||||
Browser. Durch die Offline-Nutzung werden keine personenbezogenen Daten erhoben.
|
||||
</p>
|
||||
<p>
|
||||
Sie können die gespeicherten Anwendungsdateien jederzeit löschen, indem Sie die
|
||||
Website-Daten in den Einstellungen Ihres Browsers entfernen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="source">
|
||||
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,296 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
// TEMPORARY verification harness for the PWA (not part of the committed suite).
|
||||
// Run against a production build:
|
||||
// pnpm build && pnpm exec playwright test -c playwright.pwa.config.ts
|
||||
//
|
||||
// Note: a "deployment" has to restart the node server, because adapter-node's static
|
||||
// handler (sirv, non-dev mode) stats the build directory once at startup and caches the
|
||||
// ETags forever.
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||
import { readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
createPdf,
|
||||
createZip,
|
||||
dropFiles,
|
||||
expectPdfDownload,
|
||||
pdfDropzone,
|
||||
trackPageErrors,
|
||||
waitForLoadedWorkspace,
|
||||
zipDropzone
|
||||
} from './helpers';
|
||||
|
||||
const PORT = 4179;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
// unique per test-file run: leftovers from an aborted run stay inert in old precache versions
|
||||
const PROBE = `static/pwa-update-probe-${process.pid}.svg`;
|
||||
const PROBE_URL = `/${PROBE.split('/').pop()}`;
|
||||
|
||||
let server: ChildProcess | undefined;
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function stopServer() {
|
||||
const running = server;
|
||||
server = undefined;
|
||||
if (!running) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (running.exitCode !== null) return resolve();
|
||||
running.once('exit', () => resolve());
|
||||
running.kill();
|
||||
});
|
||||
// give the listener time to release the port
|
||||
await wait(250);
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
await stopServer();
|
||||
const child = spawn('node', ['build/index.js'], {
|
||||
env: { ...process.env, PORT: String(PORT), ORIGIN: BASE }
|
||||
});
|
||||
server = child;
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk));
|
||||
}
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${BASE}/`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
/* not listening yet */
|
||||
}
|
||||
await wait(200);
|
||||
}
|
||||
throw new Error('node server did not start');
|
||||
}
|
||||
|
||||
async function rebuild(label: string) {
|
||||
console.log(`building (${label})…`);
|
||||
try {
|
||||
execSync('pnpm build', { stdio: 'ignore' });
|
||||
} catch (error) {
|
||||
throw new Error(`build failed for ${label}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const swReady = (page: Page) =>
|
||||
page.evaluate(() => navigator.serviceWorker.ready.then((r) => r.active?.scriptURL ?? null));
|
||||
|
||||
const precachedPaths = (page: Page) =>
|
||||
page.evaluate(async () => {
|
||||
const names = await caches.keys();
|
||||
const precache = names.find((name) => name.includes('precache'));
|
||||
if (!precache) return null;
|
||||
const cache = await caches.open(precache);
|
||||
return (await cache.keys()).map((request) => new URL(request.url).pathname).sort();
|
||||
});
|
||||
|
||||
const waitForPrecache = (page: Page, minimum = 50) =>
|
||||
page.waitForFunction(
|
||||
(min) =>
|
||||
caches.keys().then(async (names) => {
|
||||
const precache = names.find((name) => name.includes('precache'));
|
||||
if (!precache) return false;
|
||||
const cache = await caches.open(precache);
|
||||
return (await cache.keys()).length >= min;
|
||||
}),
|
||||
minimum
|
||||
);
|
||||
|
||||
const removeProbeFiles = () => {
|
||||
for (const file of readdirSync('static')) {
|
||||
if (file.startsWith('pwa-update-probe-')) rmSync(`static/${file}`, { force: true });
|
||||
}
|
||||
};
|
||||
|
||||
test.beforeEach(async () => {
|
||||
rmSync(PROBE, { force: true });
|
||||
await startServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopServer();
|
||||
const hadLeftovers = readdirSync('static').some((file) => file.startsWith('pwa-update-probe-'));
|
||||
removeProbeFiles();
|
||||
// purge leftover probes from an aborted run out of the precache
|
||||
if (hadLeftovers) await rebuild('afterAll');
|
||||
});
|
||||
|
||||
test('production build serves the manifest, icons and service worker', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
const manifestResponse = await request.get('/manifest.webmanifest');
|
||||
expect(manifestResponse.status()).toBe(200);
|
||||
expect(manifestResponse.headers()['content-type']).toContain('application/manifest+json');
|
||||
const manifest = await manifestResponse.json();
|
||||
expect(manifest.name).toBe('beA-Edit');
|
||||
expect(manifest.start_url).toBe('/');
|
||||
expect(manifest.display).toBe('standalone');
|
||||
|
||||
const iconPaths = [
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-maskable-512.png',
|
||||
'/icons/apple-touch-icon.png'
|
||||
];
|
||||
for (const path of iconPaths) {
|
||||
const response = await request.get(path);
|
||||
expect(response.status(), path).toBe(200);
|
||||
}
|
||||
|
||||
const swResponse = await request.get('/sw.js');
|
||||
expect(swResponse.status()).toBe(200);
|
||||
// the service worker script must never be cached aggressively by the origin
|
||||
expect(swResponse.headers()['cache-control'] ?? '').not.toMatch(/max-age=[1-9]/);
|
||||
|
||||
await page.goto(BASE);
|
||||
await expect(swReady(page)).resolves.toBe(`${BASE}/sw.js`);
|
||||
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute(
|
||||
'href',
|
||||
'/manifest.webmanifest'
|
||||
);
|
||||
await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#122f62');
|
||||
});
|
||||
|
||||
test('all documents, assets and the pdfjs worker are precached', async ({ page }) => {
|
||||
await page.goto(BASE);
|
||||
await swReady(page);
|
||||
await waitForPrecache(page);
|
||||
|
||||
const paths = await precachedPaths(page);
|
||||
expect(paths).not.toBeNull();
|
||||
for (const route of [
|
||||
'/',
|
||||
'/tools/bea',
|
||||
'/tools/merge',
|
||||
'/tools/metadata',
|
||||
'/impressum',
|
||||
'/datenschutz',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-maskable-512.png',
|
||||
'/logo.svg'
|
||||
]) {
|
||||
expect(paths, route).toContain(route);
|
||||
}
|
||||
// pdfjs worker chunk: required to render PDF thumbnails offline
|
||||
expect(paths!.some((path) => path.endsWith('.mjs'))).toBe(true);
|
||||
// the unused font drop from static/ must not bloat the precache
|
||||
expect(paths!.some((path) => path.startsWith('/Nunito/'))).toBe(false);
|
||||
});
|
||||
|
||||
test('offline: documents, ZIP processing and PDF merge work without a network', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
const pageErrors = trackPageErrors(page);
|
||||
await page.goto(BASE);
|
||||
await swReady(page);
|
||||
await waitForPrecache(page);
|
||||
|
||||
await context.setOffline(true);
|
||||
|
||||
// direct document navigation is answered by the service worker
|
||||
await page.goto(`${BASE}/tools/bea`);
|
||||
await expect(page.getByRole('heading', { name: 'beA-Edit' })).toBeVisible();
|
||||
|
||||
// the whole processing chain runs offline, including pdfjs thumbnails
|
||||
const zip = await createZip('offline-archiv', 2);
|
||||
await dropFiles(page, zipDropzone(page), [
|
||||
{ name: 'offline-archiv.zip', mimeType: 'application/zip', buffer: zip }
|
||||
]);
|
||||
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/, { timeout: 45_000 });
|
||||
|
||||
// a second tool route, still offline
|
||||
await page.goto(`${BASE}/tools/merge`);
|
||||
await expect(page.getByRole('button', { name: 'PDF-Dateien hinzufügen' })).toBeVisible();
|
||||
await dropFiles(page, pdfDropzone(page), [
|
||||
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Offline-Eins') },
|
||||
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Offline-Zwei') }
|
||||
]);
|
||||
await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible();
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'PDFs zusammenfügen' }).click();
|
||||
await expectPdfDownload(await downloadPromise, /^zusammengefuegt\.pdf$/);
|
||||
|
||||
// legal routes and the start page reload
|
||||
await page.goto(`${BASE}/datenschutz`);
|
||||
await expect(page.locator('h1, h2').first()).toBeVisible();
|
||||
await page.goto(BASE);
|
||||
await expect(page.getByRole('banner')).toBeVisible();
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('update: toast defers without interrupting, reload activates the new version', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(BASE);
|
||||
await swReady(page);
|
||||
await waitForPrecache(page);
|
||||
expect(await precachedPaths(page)).not.toContain(PROBE_URL);
|
||||
|
||||
// deploy: new build + new server process
|
||||
writeFileSync(
|
||||
PROBE,
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><desc>probe</desc></svg>'
|
||||
);
|
||||
await rebuild('with probe');
|
||||
await startServer();
|
||||
|
||||
const toast = page.getByText('Neue Version von beA-Edit verfügbar.');
|
||||
await page.reload();
|
||||
await expect(toast).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// "Später" must not reload the page: in-progress state survives
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __pwaMarker?: string }).__pwaMarker = 'intakt';
|
||||
});
|
||||
await page.getByRole('button', { name: 'Später' }).click();
|
||||
await expect(toast).toBeHidden();
|
||||
expect(
|
||||
await page.evaluate(() => (window as unknown as { __pwaMarker?: string }).__pwaMarker)
|
||||
).toBe('intakt');
|
||||
|
||||
// the offer returns on the next visit
|
||||
await page.reload();
|
||||
await expect(toast).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// "Neu laden" activates the waiting worker and reloads the page
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __pwaMarker?: string }).__pwaMarker = 'verloren';
|
||||
});
|
||||
await page.getByRole('button', { name: 'Neu laden' }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
return await page.evaluate(
|
||||
() => (window as unknown as { __pwaMarker?: string }).__pwaMarker
|
||||
);
|
||||
} catch {
|
||||
// the activation reload destroys the execution context: exactly the expected outcome
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBeUndefined();
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
await expect(swReady(page)).resolves.toBe(`${BASE}/sw.js`);
|
||||
await waitForPrecache(page, 50);
|
||||
await expect
|
||||
.poll(async () => (await precachedPaths(page))?.includes(PROBE_URL), {
|
||||
timeout: 30_000
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
rmSync(PROBE, { force: true });
|
||||
await rebuild('cleanup');
|
||||
await startServer();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
@@ -14,6 +15,51 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
adapter: adapter()
|
||||
}),
|
||||
SvelteKitPWA({
|
||||
registerType: 'prompt',
|
||||
injectRegister: false,
|
||||
manifest: {
|
||||
name: 'beA-Edit',
|
||||
short_name: 'beA-Edit',
|
||||
description:
|
||||
'PDF-Werkzeuge für die beA-Kanzlei – PDFs werden vollständig lokal im Browser verarbeitet.',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
lang: 'de',
|
||||
dir: 'ltr',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
categories: ['productivity', 'utilities'],
|
||||
background_color: '#eef4f6',
|
||||
theme_color: '#122f62',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{
|
||||
src: '/icons/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
// The plugin prepends `client/` and `prerendered/` paths itself; extending the
|
||||
// default list only adds the local font files and the pdfjs worker chunk (`*.mjs`,
|
||||
// 2.1 MB, required to render PDFs offline). `client/Nunito/**` is the unused font
|
||||
// drop from `static/` and would add 2.6 MB to the precache.
|
||||
globPatterns: ['client/**/*.{js,mjs,css,ico,png,svg,webp,webmanifest,woff2,ttf}'],
|
||||
globIgnores: ['client/Nunito/**'],
|
||||
// Prerendered route documents (see src/routes/+layout.ts) are precached, so an
|
||||
// offline navigation to any visited route — and any unknown path via the
|
||||
// fallback — is served from the cache.
|
||||
navigateFallback: '/',
|
||||
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024
|
||||
},
|
||||
// The service worker is only produced for production builds; `pnpm dev` never
|
||||
// registers one, which keeps development caches clean.
|
||||
devOptions: { enabled: false }
|
||||
})
|
||||
]
|
||||
});
|
||||