pwa / offline mode

- @vite-pwa/sveltekit: workbox precache (incl. pdfjs worker), generated manifest, prompt-style updates via PwaUpdatePrompt toast
- prerendered routes for offline navigation, PWA icons, theme-color + apple-touch-icon
- pwa offline/update e2e suite (pnpm test:pwa, playwright.pwa.config.ts)
- datenschutz: offline/install section; prettier cleanup of shadcn components
This commit is contained in:
rehlert committed 2026-10-04 12:55:46 +02:00
1 parent b7969b4c9e
commit f09384d2e6
37 files changed
+3981 -124

No files matched your search

+17
View File
@@ -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)
+32 -1
View File
@@ -22,6 +22,7 @@ The application also contains German imprint and privacy-policy routes.
- fflate for ZIP extraction and image-export ZIP creation - fflate for ZIP extraction and image-export ZIP creation
- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering - pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
- `@sveltejs/adapter-node` - `@sveltejs/adapter-node`
- PWA/offline support through `@vite-pwa/sveltekit` (Workbox precache, generated web manifest)
- pnpm with a committed lockfile - pnpm with a committed lockfile
- Prettier with Svelte and Tailwind plugins - 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 # Run Svelte and TypeScript diagnostics
pnpm check:watch # Run diagnostics in watch mode pnpm check:watch # Run diagnostics in watch mode
pnpm test:e2e # Run the Playwright toolbox tests in Chromium 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 lint # Check repository formatting with Prettier
pnpm format # Format the repository 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 │ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input │ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid │ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
│ │ ├── PwaUpdatePrompt.svelte # Service-worker registration and update/offline toast
│ │ ├── RasterizationWarning.svelte │ │ ├── RasterizationWarning.svelte
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone │ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone
│ │ ├── icons/ # Project-specific SVG components │ │ ├── icons/ # Project-specific SVG components
@@ -92,6 +95,7 @@ src/
│ └── zip-processing.ts # Archive model, transforms, and PDF export │ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/ └── routes/
├── +layout.svelte # Shared logo header and branded footer ├── +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 ├── +page.svelte # Toolbox hub and global beA ZIP drop target
├── layout.css # Tailwind, shadcn, theme, fonts, global CSS ├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
├── tools/ ├── tools/
@@ -100,15 +104,42 @@ src/
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/ │ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
├── datenschutz/+page.svelte # Privacy policy ├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint └── 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 components.json # shadcn-svelte registry configuration
Dockerfile # Node adapter production image Dockerfile # Node adapter production image
playwright.pwa.config.ts # Playwright config for `pnpm test:pwa`
plan/ # Design and implementation notes plan/ # Design and implementation notes
``` ```
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
committed. 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 ## Application flow and state
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to - `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
+6 -2
View File
@@ -12,7 +12,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"lint": "prettier --check .", "lint": "prettier --check .",
"format": "prettier --write ." "format": "prettier --write .",
"test:pwa": "playwright test -c playwright.pwa.config.ts"
}, },
"devDependencies": { "devDependencies": {
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
@@ -25,6 +26,7 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0", "@types/node": "^26.3.0",
"@vite-pwa/sveltekit": "^1.1.0",
"bits-ui": "2.18.1", "bits-ui": "2.18.1",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -39,7 +41,9 @@
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16" "vite": "^8.0.16",
"vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
}, },
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.9.1", "@cantoo/pdf-lib": "^2.9.1",
+146
View File
@@ -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.
+29
View File
@@ -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']
}
}
});
+3087 -26
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -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 // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
+2
View File
@@ -3,8 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#122f62" />
<meta name="text-scale" content="scale" /> <meta name="text-scale" content="scale" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+21 -15
View File
@@ -1,14 +1,14 @@
<script lang="ts"> <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 = { type Props = {
attachment: ZipAttachment; attachment: ZipAttachment;
class?: string; class?: string;
}; };
let { attachment, class: className = "" }: Props = $props(); let { attachment, class: className = '' }: Props = $props();
let imageUrl = $state<string | null>(null); let imageUrl = $state<string | null>(null);
let previewError = $state<string | null>(null); let previewError = $state<string | null>(null);
@@ -31,19 +31,21 @@
}; };
(async () => { (async () => {
const pdfjsLib = await import("pdfjs-dist"); const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib; 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 () => { const loadPreview = async () => {
previewError = null; previewError = null;
imageUrl = null; imageUrl = null;
if (attachment.kind === "image") { if (attachment.kind === 'image') {
const imageBuffer = attachment.data.buffer.slice( const imageBuffer = attachment.data.buffer.slice(
attachment.data.byteOffset, attachment.data.byteOffset,
attachment.data.byteOffset + attachment.data.byteLength, attachment.data.byteOffset + attachment.data.byteLength
) as ArrayBuffer; ) as ArrayBuffer;
setBlobImage(new Blob([imageBuffer])); setBlobImage(new Blob([imageBuffer]));
return; return;
@@ -62,15 +64,15 @@
} }
const viewport = page.getViewport({ scale: 1 }); const viewport = page.getViewport({ scale: 1 });
const canvasElement = document.createElement("canvas"); const canvasElement = document.createElement('canvas');
canvasElement.width = viewport.width; canvasElement.width = viewport.width;
canvasElement.height = viewport.height; canvasElement.height = viewport.height;
const canvasContext = canvasElement.getContext("2d"); const canvasContext = canvasElement.getContext('2d');
if (!canvasContext) { if (!canvasContext) {
throw new Error("Could not create thumbnail canvas context."); 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;
@@ -87,8 +89,8 @@
return; return;
} }
reject(new Error("Could not create thumbnail blob.")); reject(new Error('Could not create thumbnail blob.'));
}, "image/png"); }, 'image/png');
}); });
setBlobImage(blob); setBlobImage(blob);
@@ -96,7 +98,7 @@
await loadingTask.destroy(); await loadingTask.destroy();
} catch (error) { } catch (error) {
console.error(`Failed to render preview for ${attachment.path}`, error); console.error(`Failed to render preview for ${attachment.path}`, error);
previewError = "PDF-Vorschau konnte nicht geladen werden."; previewError = 'PDF-Vorschau konnte nicht geladen werden.';
} }
}; };
@@ -112,7 +114,9 @@
<div class={className}> <div class={className}>
{#if previewError} {#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"> <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} {previewError}
</div> </div>
{:else if imageUrl} {:else if imageUrl}
@@ -122,7 +126,9 @@
src={imageUrl} src={imageUrl}
/> />
{:else} {: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"> <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 Vorschau wird erstellt
</div> </div>
{/if} {/if}
+92
View File
@@ -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}
+13 -3
View File
@@ -2,7 +2,17 @@
</script> </script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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
<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"/> 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> </svg>
+25 -5
View File
@@ -2,9 +2,29 @@
</script> </script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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
<path d="M7.06787 12.5889H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/> fill-rule="evenodd"
<path d="M7.06787 8.71875H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/> clip-rule="evenodd"
<path d="M7.06787 16.464H12.8179" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/> 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> </svg>
+18 -5
View File
@@ -2,9 +2,22 @@
</script> </script>
<svg width="21" height="15" viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg"> <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
<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"/> fill-rule="evenodd"
<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"/> 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> </svg>
+10 -4
View File
@@ -2,8 +2,14 @@
</script> </script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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
<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"/> 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> </svg>
+14 -5
View File
@@ -2,14 +2,23 @@
</script> </script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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
<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"/> 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 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 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="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 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 d="M14 16.9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg> </svg>
+6 -1
View File
@@ -1,4 +1,9 @@
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <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" /> <circle cx="7.4751" cy="5.47498" r="4.5" stroke="currentColor" stroke-width="1.95" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 518 B

+12 -4
View File
@@ -2,11 +2,19 @@
</script> </script>
<svg width="22" height="23" viewBox="0 0 22 23" fill="none" xmlns="http://www.w3.org/2000/svg"> <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
<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"/> 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"
<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"/> 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="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="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" /> <circle cx="6.5" cy="18.4167" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
</svg> </svg>
+7 -1
View File
@@ -1,4 +1,10 @@
<svg width="24" height="23" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <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" /> <circle cx="13.7192" cy="9.90002" r="1.5" fill="currentColor" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 563 B

+5 -3
View File
@@ -3,8 +3,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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="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="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="M3 7.5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg> </svg>
+5 -1
View File
@@ -2,6 +2,10 @@
</script> </script>
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <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" /> <rect x="5" y="17" width="8" height="2" rx="1" fill="currentColor" />
</svg> </svg>
+29 -25
View File
@@ -1,33 +1,33 @@
<script lang="ts" module> <script lang="ts" module>
import {ripple} from "$lib/actions/ripple"; import { ripple } from '$lib/actions/ripple';
import {cn, type WithElementRef} from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements"; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import {type VariantProps, tv} from "tailwind-variants"; import { type VariantProps, tv } from 'tailwind-variants';
import type {Component} from "svelte"; import type { Component } from 'svelte';
export const buttonVariants = tv({ 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", 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: { variants: {
variant: { variant: {
primary: "bg-primary text-primary-foreground", primary: 'bg-primary text-primary-foreground',
secondary: "bg-linear-to-r from-secondary to-[#FF6F6D] 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]", bordered: 'text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]',
tertiary: "text-primary hover:bg-[#122F621A]", tertiary: 'text-primary hover:bg-[#122F621A]',
success: "text-white bg-[#0FAD80]" success: 'text-white bg-[#0FAD80]'
}, },
size: { size: {
standard: "px-6 h-8 rounded-[16px]", standard: 'px-6 h-8 rounded-[16px]',
large: "px-8 h-12 rounded-[24px]" large: 'px-8 h-12 rounded-[24px]'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "primary", variant: 'primary',
size: "standard", size: 'standard'
}, }
}); });
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"]; export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"]; export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> & export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & { WithElementRef<HTMLAnchorAttributes> & {
@@ -40,12 +40,12 @@
<script lang="ts"> <script lang="ts">
let { let {
class: className, class: className,
variant = "primary", variant = 'primary',
size = "standard", size = 'standard',
icon, icon,
ref = $bindable(null), ref = $bindable(null),
href = undefined, href = undefined,
type = "button", type = 'button',
disabled, disabled,
children, children,
...restProps ...restProps
@@ -57,10 +57,10 @@
use:ripple use:ripple
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn("", buttonVariants({ variant, size }), className)} class={cn('', buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href} href={disabled ? undefined : href}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? "link" : undefined} role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}
{...restProps} {...restProps}
> >
@@ -75,7 +75,11 @@
use:ripple use:ripple
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn("inline-flex items-center justify-center gap-4", buttonVariants({ variant, size }), className)} class={cn(
'inline-flex items-center justify-center gap-4',
buttonVariants({ variant, size }),
className
)}
{type} {type}
{disabled} {disabled}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="card-action" data-slot="card-action"
class={cn( 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 className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-content" 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} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<p <p
bind:this={ref} bind:this={ref}
data-slot="card-description" data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)} class={cn('text-muted-foreground text-sm', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,10 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-footer" 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} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="card-header" data-slot="card-header"
class={cn( 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 className
)} )}
{...restProps} {...restProps}
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-title" 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} {...restProps}
> >
{@render children?.()} {@render children?.()}
+8 -5
View File
@@ -1,21 +1,24 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
size = "default", size = 'default',
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: 'default' | 'sm' } = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
data-size={size} 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} {...restProps}
> >
{@render children?.()} {@render children?.()}
+8
View File
@@ -1,12 +1,18 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import { pwaInfo } from 'virtual:pwa-info';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <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> </svelte:head>
<div class="flex min-h-dvh flex-col"> <div class="flex min-h-dvh flex-col">
<header style="height:72px" class="grid shrink-0 place-content-center bg-white shadow"> <header style="height:72px" class="grid shrink-0 place-content-center bg-white shadow">
@@ -19,6 +25,8 @@
{@render children()} {@render children()}
</div> </div>
<PwaUpdatePrompt />
<footer <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" 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" style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat"
+5
View File
@@ -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;
+18
View File
@@ -402,6 +402,24 @@
Darstellung und der Optimierung seiner Website – hierzu müssen die Server-Log-Files Darstellung und der Optimierung seiner Website – hierzu müssen die Server-Log-Files
erfasst werden. erfasst werden.
</p> </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> </section>
<p class="source"> <p class="source">
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+296
View File
@@ -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();
});
+46
View File
@@ -1,5 +1,6 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
@@ -14,6 +15,51 @@ export default defineConfig({
}, },
adapter: adapter() 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 }
}) })
] ]
}); });