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
+4130 -273

No files matched your search

+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
// for information about these interfaces
declare global {
+2
View File
@@ -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">
+105 -99
View File
@@ -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>
+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>
<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>
+25 -5
View File
@@ -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>
+18 -5
View File
@@ -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>
+10 -4
View File
@@ -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>
+19 -10
View File
@@ -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>
+7 -2
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">
<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

+15 -7
View File
@@ -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>
+8 -2
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">
<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

+7 -5
View File
@@ -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>
+6 -2
View File
@@ -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>
+81 -77
View File
@@ -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}
+3 -3
View File
@@ -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?.()}
+8 -5
View File
@@ -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?.()}
+8
View File
@@ -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"
+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
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">