feat(pwa): add web manifest, SVG icon, and offline service worker

Service worker caches app shell + images for offline recipe access in the kitchen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:41:20 +02:00
parent ea9a79226a
commit 0635c2702a
4 changed files with 122 additions and 0 deletions

View File

@@ -4,6 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#2b6a3d" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Kochwas" />
<title>Kochwas</title>
%sveltekit.head%
</head>

89
src/service-worker.ts Normal file
View File

@@ -0,0 +1,89 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const APP_CACHE = `kochwas-app-${version}`;
const IMAGE_CACHE = `kochwas-images-v1`;
const APP_ASSETS = [...build, ...files];
sw.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_CACHE).then((cache) => cache.addAll(APP_ASSETS))
);
// Activate new worker without waiting for old clients to close.
void sw.skipWaiting();
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k.startsWith('kochwas-app-') && k !== APP_CACHE)
.map((k) => caches.delete(k))
);
await sw.clients.claim();
})()
);
});
sw.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== location.origin) return;
// Images served from /images/* — cache-first with background update
if (url.pathname.startsWith('/images/')) {
event.respondWith(
(async () => {
const cache = await caches.open(IMAGE_CACHE);
const cached = await cache.match(req);
const network = fetch(req)
.then((res) => {
if (res.ok) void cache.put(req, res.clone());
return res;
})
.catch(() => undefined);
return cached ?? (await network) ?? new Response('Offline', { status: 503 });
})()
);
return;
}
// App shell assets (build/* and static files) — cache-first
if (APP_ASSETS.includes(url.pathname)) {
event.respondWith(
(async () => {
const cache = await caches.open(APP_CACHE);
const cached = await cache.match(req);
return cached ?? fetch(req);
})()
);
return;
}
// API and HTML pages — network-first, fall back to cache for HTML
if (req.destination === 'document') {
event.respondWith(
(async () => {
try {
const res = await fetch(req);
const cache = await caches.open(APP_CACHE);
if (res.ok) void cache.put(req, res.clone());
return res;
} catch {
const cached = await caches.match(req);
return cached ?? new Response('Offline', { status: 503 });
}
})()
);
}
});

8
static/icon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2b6a3d"/>
<g fill="none" stroke="#ffffff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round">
<path d="M128 192 L256 320 L384 192" />
<circle cx="256" cy="256" r="128" />
</g>
<text x="256" y="460" font-family="system-ui,sans-serif" font-size="80" font-weight="700" fill="#ffffff" text-anchor="middle">Koch</text>
</svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@@ -0,0 +1,19 @@
{
"name": "Kochwas",
"short_name": "Kochwas",
"description": "Persönliches Rezeptbuch — lokal, einheitlich, küchentauglich",
"start_url": "/",
"display": "standalone",
"background_color": "#f8faf8",
"theme_color": "#2b6a3d",
"lang": "de",
"orientation": "portrait",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}