Add PWA native feel: manifest, meta tags, install prompt, SW optimization, dynamic theme-color

Configure manifest.json with scope, maskable icons, and categories. Add iOS/Android
meta tags for standalone behavior. Create pwa.css for native touch/scroll handling
and safe area insets. Add oikos-install-prompt Web Component with Chrome install
flow and iOS guidance. Optimize service worker with network-first navigation and
expanded precache (v19). Add dynamic theme-color per route and modal overlay dimming
in standalone mode. Generate placeholder icons via sharp script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-03-29 15:35:01 +02:00
parent 5838fb9243
commit 41e88e0acf
17 changed files with 1206 additions and 689 deletions
+13
View File
@@ -13,6 +13,9 @@ let activeOverlay = null;
let previouslyFocused = null;
let focusTrapHandler = null;
// Overlay-Dimming: theme-color abdunkeln im Standalone-Modus
const OVERLAY_THEME_COLOR = '#1A1A1A';
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
@@ -126,6 +129,11 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// Callback für Aufrufer (Form-Events binden etc.)
if (typeof onSave === 'function') onSave(panel);
// Standalone: Statusbar abdunkeln (Overlay-Effekt)
if (window.oikos?.setThemeColor) {
window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
}
}
// --------------------------------------------------------
@@ -155,4 +163,9 @@ export function closeModal() {
previouslyFocused.focus();
previouslyFocused = null;
}
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen
if (window.oikos?.restoreThemeColor) {
window.oikos.restoreThemeColor();
}
}
+331
View File
@@ -0,0 +1,331 @@
/**
* Modul: Install-Prompt Web Component
* Zweck: Dezentes Banner für PWA-Installation (Chrome/Android) und iOS-Anleitung
* Abhängigkeiten: Design Tokens aus tokens.css (via CSS custom properties)
*
* Verhalten:
* - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner
* - iOS (Safari): Zeigt Anleitung "Zum Home-Bildschirm"
* - Standalone-Modus: Zeigt nichts an
* - Dismiss: 30 Tage via localStorage gespeichert
*/
const DISMISS_KEY = 'oikos-install-dismissed';
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage
class OikosInstallPrompt extends HTMLElement {
constructor() {
super();
this._deferredPrompt = null;
this._shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Bereits im Standalone-Modus — nichts anzeigen
if (
window.matchMedia('(display-mode: standalone)').matches ||
navigator.standalone === true
) {
return;
}
// Dismiss noch aktiv?
const dismissed = localStorage.getItem(DISMISS_KEY);
if (dismissed && Date.now() - Number(dismissed) < DISMISS_DURATION_MS) {
return;
}
if (this._isIOS()) {
this._showIOSPrompt();
} else {
this._listenForInstallPrompt();
}
}
disconnectedCallback() {
window.removeEventListener('beforeinstallprompt', this._onBeforeInstall);
}
/** iOS Safari erkennen (kein beforeinstallprompt-Support) */
_isIOS() {
return (
navigator.standalone === undefined &&
/iPhone|iPad/.test(navigator.userAgent) &&
!window.MSStream
);
}
/** Chrome/Android: beforeinstallprompt abfangen */
_listenForInstallPrompt() {
this._onBeforeInstall = (e) => {
e.preventDefault();
this._deferredPrompt = e;
this._showBanner(false);
};
window.addEventListener('beforeinstallprompt', this._onBeforeInstall);
}
/** Banner rendern */
_showBanner(isIOS) {
this._shadow.innerHTML = '';
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
position: fixed;
bottom: calc(var(--nav-height-mobile, 56px) + env(safe-area-inset-bottom, 0px) + 8px);
left: var(--space-3, 12px);
right: var(--space-3, 12px);
z-index: var(--z-toast, 300);
pointer-events: none;
}
.banner {
display: flex;
align-items: center;
gap: var(--space-3, 12px);
padding: var(--space-3, 12px) var(--space-4, 16px);
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e8e7e2);
border-radius: var(--radius-md, 12px);
box-shadow: var(--shadow-md, 0 2px 8px rgba(0,0,0,0.08));
pointer-events: auto;
transform: translateY(calc(100% + 20px));
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.banner--visible {
transform: translateY(0);
}
.icon {
width: 40px;
height: 40px;
border-radius: var(--radius-sm, 8px);
flex-shrink: 0;
}
.text {
flex: 1;
min-width: 0;
}
.title {
font-family: var(--font-sans, system-ui);
font-size: var(--text-base, 0.875rem);
font-weight: var(--font-weight-semibold, 600);
color: var(--color-text-primary, #1c1c1a);
line-height: var(--line-height-tight, 1.25);
}
.subtitle {
font-family: var(--font-sans, system-ui);
font-size: var(--text-sm, 0.8125rem);
color: var(--color-text-secondary, #6c6b67);
line-height: var(--line-height-base, 1.5);
margin-top: 2px;
}
.btn-install {
flex-shrink: 0;
padding: var(--space-2, 8px) var(--space-4, 16px);
background: var(--color-btn-primary, #2554C7);
color: #fff;
border: none;
border-radius: var(--radius-sm, 8px);
font-family: var(--font-sans, system-ui);
font-size: var(--text-sm, 0.8125rem);
font-weight: var(--font-weight-semibold, 600);
cursor: pointer;
min-height: 36px;
min-width: 36px;
transition: background 0.15s ease;
}
.btn-install:hover {
background: var(--color-btn-primary-hover, #1E429A);
}
.btn-dismiss {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius-xs, 4px);
cursor: pointer;
color: var(--color-text-tertiary, #737370);
padding: 0;
min-height: 32px;
min-width: 32px;
transition: background 0.15s ease;
}
.btn-dismiss:hover {
background: var(--color-surface-3, #efeee9);
}
.btn-dismiss svg {
width: 18px;
height: 18px;
}
/* iOS share icon inline */
.share-icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: -0.1em;
}
@media (min-width: 1024px) {
:host {
/* Desktop: Sidebar statt Bottom-Nav, Banner unten rechts */
bottom: calc(var(--space-4, 16px) + env(safe-area-inset-bottom, 0px));
left: auto;
right: var(--space-4, 16px);
max-width: 380px;
}
}
`;
const banner = document.createElement('div');
banner.className = 'banner';
banner.setAttribute('role', 'alert');
// App-Icon
const icon = document.createElement('img');
icon.className = 'icon';
icon.src = '/icons/icon-192.png';
icon.alt = 'Oikos';
icon.width = 40;
icon.height = 40;
banner.appendChild(icon);
// Text
const text = document.createElement('div');
text.className = 'text';
const title = document.createElement('div');
title.className = 'title';
title.textContent = 'Oikos installieren';
const subtitle = document.createElement('div');
subtitle.className = 'subtitle';
if (isIOS) {
// iOS: Teilen-Icon als SVG inline
subtitle.innerHTML = '';
subtitle.append(
document.createTextNode('Tippe auf '),
this._createShareIcon(),
document.createTextNode(' → „Zum Home-Bildschirm"')
);
} else {
subtitle.textContent = 'Zur App hinzufügen';
}
text.appendChild(title);
text.appendChild(subtitle);
banner.appendChild(text);
// Install-Button (nur Chrome/Android)
if (!isIOS) {
const btn = document.createElement('button');
btn.className = 'btn-install';
btn.textContent = 'Installieren';
btn.addEventListener('click', () => this._onInstallClick());
banner.appendChild(btn);
}
// Dismiss-Button
const dismiss = document.createElement('button');
dismiss.className = 'btn-dismiss';
dismiss.setAttribute('aria-label', 'Schließen');
dismiss.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
dismiss.addEventListener('click', () => this._dismiss());
banner.appendChild(dismiss);
this._shadow.appendChild(style);
this._shadow.appendChild(banner);
// Slide-in Animation nach nächstem Frame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
banner.classList.add('banner--visible');
});
});
}
/** iOS Teilen-Icon (Box mit Pfeil nach oben) */
_createShareIcon() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.classList.add('share-icon');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8');
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polyline.setAttribute('points', '16 6 12 2 8 6');
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', '12');
line.setAttribute('y1', '2');
line.setAttribute('x2', '12');
line.setAttribute('y2', '15');
svg.appendChild(path);
svg.appendChild(polyline);
svg.appendChild(line);
return svg;
}
/** Install-Button geklickt */
async _onInstallClick() {
if (!this._deferredPrompt) return;
try {
this._deferredPrompt.prompt();
const result = await this._deferredPrompt.userChoice;
console.log('[oikos-install-prompt] Ergebnis:', result.outcome);
if (result.outcome === 'accepted') {
this._remove();
}
} catch (err) {
console.error('[oikos-install-prompt] Fehler:', err);
}
this._deferredPrompt = null;
}
/** Dismiss: 30 Tage merken, Banner entfernen */
_dismiss() {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
this._remove();
}
/** Banner mit Slide-out entfernen */
_remove() {
const banner = this._shadow.querySelector('.banner');
if (!banner) return;
banner.classList.remove('banner--visible');
banner.addEventListener('transitionend', () => this.remove(), { once: true });
}
/** iOS: Banner direkt anzeigen */
_showIOSPrompt() {
this._showBanner(true);
}
}
customElements.define('oikos-install-prompt', OikosInstallPrompt);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

+18 -3
View File
@@ -2,14 +2,24 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#2563EB" />
<!-- Viewport: edge-to-edge, kein Auto-Zoom bei Inputs -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
<!-- PWA / Theme -->
<meta name="theme-color" content="#007AFF" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1C1C1E" media="(prefers-color-scheme: dark)" />
<meta name="mobile-web-app-capable" content="yes" />
<!-- iOS-spezifisch -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Oikos" />
<meta name="description" content="Oikos — Familienplaner" />
<title>Oikos</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
@@ -24,6 +34,7 @@
<!-- Styles -->
<link rel="stylesheet" href="/styles/tokens.css" />
<link rel="stylesheet" href="/styles/reset.css" />
<link rel="stylesheet" href="/styles/pwa.css" />
<link rel="stylesheet" href="/styles/layout.css" />
<link rel="stylesheet" href="/styles/login.css" />
<link rel="stylesheet" href="/styles/dashboard.css" />
@@ -60,6 +71,10 @@
<script type="module" src="/api.js"></script>
<script type="module" src="/router.js"></script>
<!-- Install-Prompt (PWA) -->
<oikos-install-prompt></oikos-install-prompt>
<script type="module" src="/components/oikos-install-prompt.js"></script>
<!-- Service Worker registrieren -->
<script src="/sw-register.js" defer></script>
</body>
+12 -35
View File
@@ -1,43 +1,20 @@
{
"name": "Oikos Familienplaner",
"short_name": "Oikos",
"description": "Selbstgehosteter Familienplaner für Kalender, Aufgaben, Einkauf und mehr.",
"description": "Selbstgehosteter Familienplaner",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#F5F4F1",
"theme_color": "#2563EB",
"orientation": "portrait-primary",
"lang": "de",
"theme_color": "#007AFF",
"background_color": "#F5F5F7",
"lang": "de-DE",
"categories": ["productivity", "lifestyle"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
}
]
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": []
}
+61 -12
View File
@@ -8,21 +8,61 @@ import { auth } from '/api.js';
// --------------------------------------------------------
// Routen-Definitionen
// Jede Route hat: path, page (dynamisch geladen), requiresAuth
// Jede Route hat: path, page (dynamisch geladen), requiresAuth, module (für theme-color)
// --------------------------------------------------------
const ROUTES = [
{ path: '/login', page: '/pages/login.js', requiresAuth: false },
{ path: '/', page: '/pages/dashboard.js', requiresAuth: true },
{ path: '/tasks', page: '/pages/tasks.js', requiresAuth: true },
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true },
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true },
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true },
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true },
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true },
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true },
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true },
{ path: '/login', page: '/pages/login.js', requiresAuth: false, module: null },
{ path: '/', page: '/pages/dashboard.js', requiresAuth: true, module: 'dashboard' },
{ path: '/tasks', page: '/pages/tasks.js', requiresAuth: true, module: 'tasks' },
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true, module: 'shopping' },
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' },
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
];
// --------------------------------------------------------
// Standalone-Modus: Dynamische theme-color Anpassung
// Statusbar-Farbe spiegelt aktuelle Seite / Modal-State wider
// --------------------------------------------------------
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| navigator.standalone === true;
/**
* Setzt die theme-color Meta-Tags (Light + Dark Variante).
* @param {string} lightColor
* @param {string} [darkColor] — Falls nicht angegeben, wird lightColor für beide gesetzt
*/
function setThemeColor(lightColor, darkColor) {
if (!isStandalone) return;
const metas = document.querySelectorAll('meta[name="theme-color"]');
if (metas.length >= 2) {
metas[0].setAttribute('content', lightColor);
metas[1].setAttribute('content', darkColor || lightColor);
} else if (metas.length === 1) {
metas[0].setAttribute('content', lightColor);
}
}
/** Liest eine CSS Custom Property vom :root */
function getCSSToken(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/** Setzt theme-color passend zum aktuellen Modul */
function updateThemeColorForRoute(route) {
if (!route?.module) {
setThemeColor('#007AFF', '#1C1C1E');
return;
}
const color = getCSSToken(`--module-${route.module}`);
if (color) {
setThemeColor(color, color);
}
}
// --------------------------------------------------------
// Modul-Cache: verhindert redundante dynamic imports bei Navigation
// --------------------------------------------------------
@@ -88,6 +128,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
await renderPage(route);
updateNav(path);
updateThemeColorForRoute(route);
}
/**
@@ -348,4 +389,12 @@ window.addEventListener('auth:expired', () => {
navigate(location.pathname, false);
// Globale Exporte
window.oikos = { navigate, showToast };
window.oikos = {
navigate,
showToast,
setThemeColor,
restoreThemeColor: () => {
const route = ROUTES.find((r) => r.path === currentPath);
updateThemeColorForRoute(route);
},
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Modul: PWA Native Feel
* Zweck: Natives Touch- und Scrollverhalten, Safe Areas, Touch-Targets
* Abhängigkeiten: tokens.css, layout.css
*/
/* ── Kein Rubber-Banding / Pull-to-Refresh des Browsers ── */
html, body {
overscroll-behavior: none;
}
/* ── Kein Tap-Highlight auf allen Elementen (Android Chrome) ──
* reset.css setzt es nur auf html; hier global für alle Elemente */
* {
-webkit-tap-highlight-color: transparent;
}
/* ── Safe Area Insets (Notch, Dynamic Island, Gesture Bar) ── */
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* ── Bottom Nav über der Gesture Bar ──
* layout.css nutzt --safe-area-inset-bottom Token;
* hier als Fallback direkt via env() */
.nav-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* ── Touch-Targets: min 44×44px (Apple HIG / WCAG 2.5.5) ── */
button, a, [role="button"], input[type="checkbox"], input[type="radio"] {
min-height: 44px;
min-width: 44px;
}
/* ── Smooth Momentum-Scrolling in scrollbaren Containern ── */
.scroll-container,
.nav-bottom__scroll {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
/* ── Kein Text-Selection in UI-Elementen (nur in Content-Bereichen) ── */
nav,
.nav-bottom,
.nav-sidebar,
.cal-toolbar,
.tasks-toolbar,
.notes-toolbar,
.contacts-toolbar,
.modal-panel__header {
-webkit-user-select: none;
user-select: none;
}
/* ── Standalone-Modus: Status-Bar-Bereich berücksichtigen ── */
@media (display-mode: standalone) {
body {
padding-top: env(safe-area-inset-top);
}
}
+11 -1
View File
@@ -1,6 +1,7 @@
/**
* Modul: Service Worker Registrierung
* Zweck: Ausgelagert aus index.html um CSP-Inline-Script-Verletzung zu vermeiden
* Zweck: Ausgelagert aus index.html um CSP-Inline-Script-Verletzung zu vermeiden.
* Handhabt nahtlose Updates via controllerchange.
* Abhängigkeiten: keine
*/
@@ -10,4 +11,13 @@ if ('serviceWorker' in navigator) {
console.warn('[SW] Registrierung fehlgeschlagen:', err);
});
});
// Nahtloses Update: Neuer SW hat skipWaiting() + clients.claim() aufgerufen
// → Controller wechselt → Seite neu laden für konsistenten Stand
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
}
+45 -4
View File
@@ -12,9 +12,9 @@
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
*/
const SHELL_CACHE = 'oikos-shell-v18';
const PAGES_CACHE = 'oikos-pages-v18';
const ASSETS_CACHE = 'oikos-assets-v18';
const SHELL_CACHE = 'oikos-shell-v19';
const PAGES_CACHE = 'oikos-pages-v19';
const ASSETS_CACHE = 'oikos-assets-v19';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
// App-Shell: sofort benötigt für ersten Render
@@ -28,6 +28,7 @@ const APP_SHELL = [
'/lucide.min.js',
'/styles/tokens.css',
'/styles/reset.css',
'/styles/pwa.css',
'/styles/layout.css',
'/styles/login.css',
'/styles/dashboard.css',
@@ -39,10 +40,15 @@ const APP_SHELL = [
'/styles/contacts.css',
'/styles/budget.css',
'/styles/settings.css',
'/components/oikos-install-prompt.js',
'/manifest.json',
'/favicon.ico',
'/icons/favicon-32.png',
'/icons/apple-touch-icon.png',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-maskable-192.png',
'/icons/icon-maskable-512.png',
];
// Seiten-Module: lazy geladen, aber vorab gecacht für Offline
@@ -107,6 +113,12 @@ self.addEventListener('fetch', (event) => {
// Nur GET cachen
if (request.method !== 'GET') return;
// Navigation Requests: Network-first, Fallback auf gecachte Shell
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request, SHELL_CACHE));
return;
}
// Bilder + Fonts: Cache-First, langer TTL
if (isAsset(url.pathname)) {
event.respondWith(cacheFirst(request, ASSETS_CACHE));
@@ -119,10 +131,39 @@ self.addEventListener('fetch', (event) => {
return;
}
// App-Shell (HTML, JS, CSS): Stale-While-Revalidate
// App-Shell (JS, CSS): Stale-While-Revalidate
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
});
// --------------------------------------------------------
// Strategie: Network-First (für Navigation Requests)
// Versucht Netzwerk, fällt auf gecachte Shell zurück (Offline).
// --------------------------------------------------------
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.ok && response.type === 'basic') {
cache.put(request, response.clone());
}
return response;
} catch {
// Offline: gecachte Shell liefern
const cached = await cache.match(request);
if (cached) return cached;
// Fallback auf index.html (SPA-Routing)
const shell = await cache.match('/index.html');
if (shell) return shell;
return new Response('Keine Verbindung', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
}
// --------------------------------------------------------
// Strategie: Stale-While-Revalidate
// Liefert sofort aus Cache, aktualisiert im Hintergrund.