mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(todo): add PWA support with offline capabilities
- Add web app manifest with app metadata and shortcuts - Add service worker with network-first, cache-first, and network-only strategies - Add offline fallback page with auto-reload on reconnection - Add SVG placeholder icon for PWA - Add PWA meta tags for iOS, Android, and Windows support - Add comprehensive PWA guide documentation (docs/PWA_GUIDE.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d45a9db3fc
commit
1ac74c9bf5
6 changed files with 1292 additions and 2 deletions
|
|
@ -1,9 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#8b5cf6" />
|
||||
<meta name="msapplication-TileColor" content="#8b5cf6" />
|
||||
|
||||
<!-- Apple iOS PWA -->
|
||||
<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="Todo" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-config" content="none" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
25
apps/todo/apps/web/static/icons/icon.svg
Normal file
25
apps/todo/apps/web/static/icons/icon.svg
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6"/>
|
||||
<stop offset="100%" style="stop-color:#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||
<!-- Checkbox icon -->
|
||||
<g fill="none" stroke="white" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Checkbox outline -->
|
||||
<rect x="120" y="140" width="120" height="120" rx="24" fill="none"/>
|
||||
<!-- Checkmark -->
|
||||
<path d="M150 200 L175 225 L220 170"/>
|
||||
<!-- Lines (task text) -->
|
||||
<line x1="280" y1="170" x2="392" y2="170"/>
|
||||
<line x1="280" y1="230" x2="350" y2="230"/>
|
||||
<!-- Second checkbox (unchecked) -->
|
||||
<rect x="120" y="300" width="120" height="120" rx="24" fill="none"/>
|
||||
<!-- Lines for second task -->
|
||||
<line x1="280" y1="330" x2="392" y2="330"/>
|
||||
<line x1="280" y1="390" x2="320" y2="390"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
71
apps/todo/apps/web/static/manifest.json
Normal file
71
apps/todo/apps/web/static/manifest.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "Todo - Aufgabenverwaltung",
|
||||
"short_name": "Todo",
|
||||
"description": "Aufgaben und Projekte verwalten mit Kanban-Board, Subtasks und mehr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#8b5cf6",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Neue Aufgabe",
|
||||
"short_name": "Neu",
|
||||
"description": "Neue Aufgabe erstellen",
|
||||
"url": "/?action=new",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Kanban Board",
|
||||
"short_name": "Kanban",
|
||||
"description": "Kanban-Ansicht öffnen",
|
||||
"url": "/kanban",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Einstellungen",
|
||||
"short_name": "Settings",
|
||||
"description": "App-Einstellungen öffnen",
|
||||
"url": "/settings",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"screenshots": [],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
228
apps/todo/apps/web/static/offline.html
Normal file
228
apps/todo/apps/web/static/offline.html
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - Todo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e1b2e 0%, #2d2640 100%);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.container {
|
||||
background: rgba(30, 27, 46, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
h1 {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
p {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 9999px;
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px -10px rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tips {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tips h2 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tips ul {
|
||||
list-style: none;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tips ul {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.tips li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tips li::before {
|
||||
content: '•';
|
||||
color: #8b5cf6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>Du bist offline</h1>
|
||||
<p>Keine Internetverbindung. Sobald du wieder online bist, kannst du deine Aufgaben verwalten.</p>
|
||||
|
||||
<div class="status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Verbindung wird gesucht...</span>
|
||||
</div>
|
||||
|
||||
<button onclick="location.reload()">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
|
||||
<div class="tips">
|
||||
<h2>Was du tun kannst</h2>
|
||||
<ul>
|
||||
<li>Überprüfe deine WLAN-Verbindung</li>
|
||||
<li>Prüfe deine mobilen Daten</li>
|
||||
<li>Versuche es in einigen Sekunden erneut</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Automatisch neu laden, wenn wieder online
|
||||
window.addEventListener('online', () => {
|
||||
location.reload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
154
apps/todo/apps/web/static/sw.js
Normal file
154
apps/todo/apps/web/static/sw.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
const CACHE_NAME = 'todo-v1';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
|
||||
// Assets, die immer gecacht werden sollen
|
||||
const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.json'];
|
||||
|
||||
// Cache-Strategien für verschiedene Ressourcen
|
||||
const CACHE_STRATEGIES = {
|
||||
// Netzwerk zuerst, dann Cache (für HTML/Navigation)
|
||||
networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/],
|
||||
// Cache zuerst, dann Netzwerk (für Assets)
|
||||
cacheFirst: [
|
||||
/\.css$/,
|
||||
/\.js$/,
|
||||
/\.woff2?$/,
|
||||
/\.ttf$/,
|
||||
/\.otf$/,
|
||||
/\.svg$/,
|
||||
/\.png$/,
|
||||
/\.jpg$/,
|
||||
/\.jpeg$/,
|
||||
/\.webp$/,
|
||||
/\.ico$/,
|
||||
/\/_app\//,
|
||||
],
|
||||
// Nur Netzwerk (für API-Calls)
|
||||
networkOnly: [/\/api\//, /localhost:3018/],
|
||||
};
|
||||
|
||||
// Service Worker Installation
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('Todo Service Worker: Caching static assets');
|
||||
return cache.addAll(STATIC_CACHE_URLS);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Service Worker Aktivierung
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((cacheName) => cacheName.startsWith('todo-') && cacheName !== CACHE_NAME)
|
||||
.map((cacheName) => caches.delete(cacheName))
|
||||
);
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch-Event Handler
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Ignoriere Chrome Extension Requests
|
||||
if (url.protocol === 'chrome-extension:') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignoriere Cross-Origin Requests (z.B. Backend API)
|
||||
if (url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bestimme die Cache-Strategie
|
||||
const strategy = getStrategy(url.pathname);
|
||||
|
||||
if (strategy === 'networkFirst') {
|
||||
event.respondWith(networkFirst(request));
|
||||
} else if (strategy === 'cacheFirst') {
|
||||
event.respondWith(cacheFirst(request));
|
||||
} else if (strategy === 'networkOnly') {
|
||||
event.respondWith(networkOnly(request));
|
||||
} else {
|
||||
// Standard: Network First
|
||||
event.respondWith(networkFirst(request));
|
||||
}
|
||||
});
|
||||
|
||||
// Cache-Strategien Implementierung
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Wenn es eine Navigation ist und wir offline sind, zeige die Offline-Seite
|
||||
if (request.mode === 'navigate') {
|
||||
const offlineResponse = await caches.match(OFFLINE_URL);
|
||||
if (offlineResponse) {
|
||||
return offlineResponse;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('Todo SW: Fetch failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkOnly(request) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// Hilfsfunktion zur Bestimmung der Cache-Strategie
|
||||
function getStrategy(pathname) {
|
||||
for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) {
|
||||
if (patterns.some((pattern) => pattern.test(pathname))) {
|
||||
return strategy;
|
||||
}
|
||||
}
|
||||
return 'networkFirst';
|
||||
}
|
||||
|
||||
// Message Handler für Updates
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
792
docs/PWA_GUIDE.md
Normal file
792
docs/PWA_GUIDE.md
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
# PWA Guide - Progressive Web Apps im Monorepo
|
||||
|
||||
Diese Anleitung beschreibt, wie eine bestehende Web-App in eine installierbare PWA (Progressive Web App) umgewandelt wird.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Eine PWA benötigt folgende Komponenten:
|
||||
|
||||
| Komponente | Datei | Zweck |
|
||||
|------------|-------|-------|
|
||||
| Web App Manifest | `static/manifest.json` | App-Metadaten, Icons, Shortcuts |
|
||||
| Service Worker | `static/sw.js` | Offline-Support, Caching |
|
||||
| Offline-Seite | `static/offline.html` | Fallback wenn offline |
|
||||
| App-Icons | `static/icons/` | Icons für verschiedene Plattformen |
|
||||
| Meta-Tags | `src/app.html` | PWA-Konfiguration im HTML |
|
||||
| SW Registration | `+layout.svelte` | Service Worker starten |
|
||||
|
||||
---
|
||||
|
||||
## Schritt 1: App-Icons erstellen
|
||||
|
||||
### Verzeichnis anlegen
|
||||
|
||||
```
|
||||
static/icons/
|
||||
├── icon.svg # Basis-Icon (SVG, skalierbar)
|
||||
├── icon-72x72.png # Optional: PNG für ältere Browser
|
||||
├── icon-96x96.png
|
||||
├── icon-128x128.png
|
||||
├── icon-144x144.png
|
||||
├── icon-152x152.png
|
||||
├── icon-192x192.png
|
||||
├── icon-384x384.png
|
||||
├── icon-512x512.png
|
||||
└── apple-touch-icon.png # 180x180 für iOS
|
||||
```
|
||||
|
||||
### SVG-Icon Vorlage
|
||||
|
||||
SVG ist ideal, da es skalierbar ist und nur eine Datei benötigt:
|
||||
|
||||
```svg
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#8b5cf6"/>
|
||||
<stop offset="100%" style="stop-color:#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#grad)"/>
|
||||
<!-- App-spezifisches Icon hier -->
|
||||
</svg>
|
||||
```
|
||||
|
||||
### Farbschema pro App
|
||||
|
||||
| App | Primary Color | Gradient |
|
||||
|-----|---------------|----------|
|
||||
| Todo | `#8b5cf6` | `#8b5cf6` → `#7c3aed` |
|
||||
| Chat | `#3b82f6` | `#3b82f6` → `#2563eb` |
|
||||
| Picture | `#ec4899` | `#ec4899` → `#db2777` |
|
||||
| Zitare | `#f59e0b` | `#f59e0b` → `#d97706` |
|
||||
| Calendar | `#10b981` | `#10b981` → `#059669` |
|
||||
| Contacts | `#6366f1` | `#6366f1` → `#4f46e5` |
|
||||
| Mana Games | `#00ff88` | `#00ff88` → `#00cc6a` |
|
||||
|
||||
---
|
||||
|
||||
## Schritt 2: Web App Manifest
|
||||
|
||||
### Datei: `static/manifest.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "App Name - Beschreibung",
|
||||
"short_name": "App Name",
|
||||
"description": "Kurze Beschreibung der App",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#8b5cf6",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity"],
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Shortcut Name",
|
||||
"short_name": "Kurz",
|
||||
"description": "Beschreibung",
|
||||
"url": "/path?action=shortcut",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest-Felder erklärt
|
||||
|
||||
| Feld | Beschreibung | Beispiel |
|
||||
|------|--------------|----------|
|
||||
| `name` | Vollständiger App-Name | "Todo - Aufgabenverwaltung" |
|
||||
| `short_name` | Kurzname (max 12 Zeichen) | "Todo" |
|
||||
| `description` | App-Beschreibung | "Aufgaben verwalten" |
|
||||
| `start_url` | Start-URL beim Öffnen | "/" |
|
||||
| `display` | Anzeigemodus | "standalone", "fullscreen", "minimal-ui" |
|
||||
| `background_color` | Hintergrund beim Laden | "#ffffff" |
|
||||
| `theme_color` | Statusleisten-Farbe | "#8b5cf6" |
|
||||
| `orientation` | Bildschirmorientierung | "any", "portrait", "landscape" |
|
||||
| `categories` | App Store Kategorien | ["productivity", "utilities"] |
|
||||
| `lang` | Sprache | "de" |
|
||||
| `icons` | App-Icons Array | Siehe oben |
|
||||
| `shortcuts` | Quick Actions | Siehe oben |
|
||||
|
||||
### Display-Modi
|
||||
|
||||
| Modus | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `fullscreen` | Komplett bildschirmfüllend, keine Browser-UI |
|
||||
| `standalone` | Wie native App, mit Statusleiste |
|
||||
| `minimal-ui` | Mit minimaler Browser-Navigation |
|
||||
| `browser` | Normaler Browser-Tab |
|
||||
|
||||
---
|
||||
|
||||
## Schritt 3: Service Worker
|
||||
|
||||
### Datei: `static/sw.js`
|
||||
|
||||
```javascript
|
||||
const CACHE_NAME = 'app-name-v1';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
|
||||
// Statische Assets die immer gecacht werden
|
||||
const STATIC_CACHE_URLS = [
|
||||
'/',
|
||||
'/offline.html',
|
||||
'/icons/icon.svg',
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Cache-Strategien für verschiedene Ressourcen
|
||||
const CACHE_STRATEGIES = {
|
||||
// Network First: Immer aktuell, Cache als Fallback
|
||||
networkFirst: [
|
||||
/\/$/, // Root
|
||||
/\.html$/, // HTML-Dateien
|
||||
/^\/app/, // App-Routen
|
||||
],
|
||||
|
||||
// Cache First: Schnell laden, im Hintergrund aktualisieren
|
||||
cacheFirst: [
|
||||
/\.css$/,
|
||||
/\.js$/,
|
||||
/\.woff2?$/,
|
||||
/\.ttf$/,
|
||||
/\.svg$/,
|
||||
/\.png$/,
|
||||
/\.jpg$/,
|
||||
/\.jpeg$/,
|
||||
/\.webp$/,
|
||||
/\/_app\//, // SvelteKit Assets
|
||||
],
|
||||
|
||||
// Network Only: Nie cachen (API-Calls)
|
||||
networkOnly: [
|
||||
/\/api\//,
|
||||
/localhost:\d+/,
|
||||
]
|
||||
};
|
||||
|
||||
// Installation
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('SW: Caching static assets');
|
||||
return cache.addAll(STATIC_CACHE_URLS);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Aktivierung (alte Caches löschen)
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name.startsWith('app-name-') && name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch-Handler
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Chrome Extensions ignorieren
|
||||
if (url.protocol === 'chrome-extension:') return;
|
||||
|
||||
// Cross-Origin ignorieren
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
const strategy = getStrategy(url.pathname);
|
||||
|
||||
if (strategy === 'networkFirst') {
|
||||
event.respondWith(networkFirst(request));
|
||||
} else if (strategy === 'cacheFirst') {
|
||||
event.respondWith(cacheFirst(request));
|
||||
} else if (strategy === 'networkOnly') {
|
||||
event.respondWith(fetch(request));
|
||||
} else {
|
||||
event.respondWith(networkFirst(request));
|
||||
}
|
||||
});
|
||||
|
||||
// Network First Strategy
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
// Offline-Seite für Navigation
|
||||
if (request.mode === 'navigate') {
|
||||
const offline = await caches.match(OFFLINE_URL);
|
||||
if (offline) return offline;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache First Strategy
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('SW: Fetch failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategie ermitteln
|
||||
function getStrategy(pathname) {
|
||||
for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) {
|
||||
if (patterns.some((pattern) => pattern.test(pathname))) {
|
||||
return strategy;
|
||||
}
|
||||
}
|
||||
return 'networkFirst';
|
||||
}
|
||||
|
||||
// Update-Nachricht empfangen
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Caching-Strategien erklärt
|
||||
|
||||
| Strategie | Wann verwenden | Verhalten |
|
||||
|-----------|----------------|-----------|
|
||||
| **Network First** | HTML, dynamische Inhalte | Versucht Netzwerk, fällt auf Cache zurück |
|
||||
| **Cache First** | Statische Assets (CSS, JS, Bilder) | Lädt aus Cache, aktualisiert im Hintergrund |
|
||||
| **Network Only** | API-Calls, Echtzeit-Daten | Kein Caching, immer Netzwerk |
|
||||
| **Stale While Revalidate** | Häufig aktualisierte Inhalte | Cache sofort, Netzwerk im Hintergrund |
|
||||
|
||||
### Cache-Versionierung
|
||||
|
||||
Bei Updates den Cache-Namen erhöhen:
|
||||
|
||||
```javascript
|
||||
// Version erhöhen bei Breaking Changes
|
||||
const CACHE_NAME = 'app-name-v2'; // War v1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 4: Offline-Seite
|
||||
|
||||
### Datei: `static/offline.html`
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - App Name</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e1b2e 0%, #2d2640 100%);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.container {
|
||||
background: rgba(30, 27, 46, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
h1 { color: #f3f4f6; }
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
p { color: #9ca3af; }
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 9999px;
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px -10px rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>Du bist offline</h1>
|
||||
<p>Keine Internetverbindung. Sobald du wieder online bist, kannst du die App nutzen.</p>
|
||||
|
||||
<div class="status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Verbindung wird gesucht...</span>
|
||||
</div>
|
||||
|
||||
<button onclick="location.reload()">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Automatisch neu laden wenn wieder online
|
||||
window.addEventListener('online', () => {
|
||||
location.reload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 5: PWA Meta-Tags
|
||||
|
||||
### Datei: `src/app.html`
|
||||
|
||||
Im `<head>` hinzufügen:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#8b5cf6" />
|
||||
<meta name="msapplication-TileColor" content="#8b5cf6" />
|
||||
|
||||
<!-- Apple iOS PWA -->
|
||||
<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="App Name" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-config" content="none" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Meta-Tags erklärt
|
||||
|
||||
| Tag | Zweck |
|
||||
|-----|-------|
|
||||
| `<link rel="manifest">` | Verlinkt das Web App Manifest |
|
||||
| `<meta name="theme-color">` | Farbe der Browser-Statusleiste |
|
||||
| `apple-mobile-web-app-capable` | Ermöglicht "Add to Home Screen" auf iOS |
|
||||
| `apple-mobile-web-app-status-bar-style` | iOS Statusleisten-Stil |
|
||||
| `apple-mobile-web-app-title` | App-Name auf iOS Homescreen |
|
||||
| `apple-touch-icon` | Icon für iOS Homescreen |
|
||||
| `msapplication-TileColor` | Windows Tile Farbe |
|
||||
|
||||
---
|
||||
|
||||
## Schritt 6: Service Worker Registration
|
||||
|
||||
### Datei: `src/routes/(app)/+layout.svelte`
|
||||
|
||||
Im `onMount` hinzufügen:
|
||||
|
||||
```typescript
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(async () => {
|
||||
// ... andere Initialisierungen ...
|
||||
|
||||
// Service Worker registrieren
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
});
|
||||
console.log('PWA: Service Worker registered', registration.scope);
|
||||
|
||||
// Update-Erkennung
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// Neue Version verfügbar
|
||||
console.log('PWA: New version available');
|
||||
// Optional: Benutzer informieren
|
||||
showUpdateNotification();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('PWA: Service Worker registration failed', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function showUpdateNotification() {
|
||||
// Optional: Toast/Banner anzeigen
|
||||
// "Neue Version verfügbar - Seite neu laden?"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 7: Optionale Features
|
||||
|
||||
### Install-Prompt anzeigen
|
||||
|
||||
```typescript
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
let showInstallButton = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
showInstallButton = true;
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
showInstallButton = false;
|
||||
deferredPrompt = null;
|
||||
});
|
||||
});
|
||||
|
||||
async function installApp() {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
console.log('PWA installed');
|
||||
}
|
||||
|
||||
deferredPrompt = null;
|
||||
showInstallButton = false;
|
||||
}
|
||||
```
|
||||
|
||||
```svelte
|
||||
{#if showInstallButton}
|
||||
<button onclick={installApp}>
|
||||
App installieren
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Update-Banner
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let showUpdateBanner = $state(false);
|
||||
let waitingWorker: ServiceWorker | null = null;
|
||||
|
||||
function handleUpdate() {
|
||||
if (waitingWorker) {
|
||||
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showUpdateBanner}
|
||||
<div class="update-banner">
|
||||
<span>Neue Version verfügbar</span>
|
||||
<button onclick={handleUpdate}>Aktualisieren</button>
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Offline-Status anzeigen
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let isOnline = $state(navigator.onLine);
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('online', () => isOnline = true);
|
||||
window.addEventListener('offline', () => isOnline = false);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isOnline}
|
||||
<div class="offline-indicator">
|
||||
Offline
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checkliste
|
||||
|
||||
### Vor dem Deployment
|
||||
|
||||
- [ ] `manifest.json` erstellt mit korrekten Metadaten
|
||||
- [ ] App-Icons in allen benötigten Größen vorhanden
|
||||
- [ ] `sw.js` erstellt mit passenden Cache-Strategien
|
||||
- [ ] `offline.html` erstellt mit App-Branding
|
||||
- [ ] PWA Meta-Tags in `app.html` eingefügt
|
||||
- [ ] Service Worker Registration im Layout
|
||||
- [ ] Theme-Color passt zur App
|
||||
- [ ] `start_url` ist korrekt
|
||||
- [ ] Shortcuts sind sinnvoll definiert
|
||||
|
||||
### Testen
|
||||
|
||||
```bash
|
||||
# Chrome DevTools
|
||||
1. F12 öffnen
|
||||
2. Application Tab
|
||||
3. "Manifest" prüfen
|
||||
4. "Service Workers" prüfen
|
||||
5. "Cache Storage" prüfen
|
||||
|
||||
# Lighthouse Audit
|
||||
1. F12 öffnen
|
||||
2. Lighthouse Tab
|
||||
3. "Progressive Web App" aktivieren
|
||||
4. "Analyze page load" klicken
|
||||
```
|
||||
|
||||
### PWA-Kriterien (Lighthouse)
|
||||
|
||||
| Kriterium | Anforderung |
|
||||
|-----------|-------------|
|
||||
| Installierbar | Manifest + Service Worker |
|
||||
| Offline-fähig | Service Worker mit Caching |
|
||||
| HTTPS | Erforderlich (außer localhost) |
|
||||
| Responsive | Viewport Meta-Tag |
|
||||
| Schnell | First Contentful Paint < 3s |
|
||||
|
||||
---
|
||||
|
||||
## Referenz-Implementierungen
|
||||
|
||||
### Im Monorepo
|
||||
|
||||
| App | Verzeichnis |
|
||||
|-----|-------------|
|
||||
| Mana Games | `games/mana-games/apps/web/public/` |
|
||||
| Todo | `apps/todo/apps/web/static/` |
|
||||
|
||||
### Externe Ressourcen
|
||||
|
||||
- [Web.dev PWA Guide](https://web.dev/progressive-web-apps/)
|
||||
- [MDN Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest)
|
||||
- [Workbox (Google's SW Library)](https://developer.chrome.com/docs/workbox/)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service Worker wird nicht registriert
|
||||
|
||||
```
|
||||
Fehler: "Service Worker registration failed"
|
||||
```
|
||||
|
||||
**Lösungen:**
|
||||
1. HTTPS verwenden (oder localhost)
|
||||
2. Pfad zu `sw.js` prüfen (muss im root sein)
|
||||
3. Browser-Cache leeren
|
||||
4. DevTools → Application → Service Workers → "Update on reload" aktivieren
|
||||
|
||||
### Manifest wird nicht erkannt
|
||||
|
||||
```
|
||||
Fehler: "No matching service worker detected"
|
||||
```
|
||||
|
||||
**Lösungen:**
|
||||
1. `<link rel="manifest">` im `<head>` prüfen
|
||||
2. JSON-Syntax in `manifest.json` validieren
|
||||
3. Icons-Pfade prüfen
|
||||
|
||||
### Offline-Seite wird nicht angezeigt
|
||||
|
||||
**Lösungen:**
|
||||
1. `offline.html` ist in `STATIC_CACHE_URLS` enthalten
|
||||
2. Service Worker ist aktiviert
|
||||
3. Cache-Name stimmt überein
|
||||
|
||||
### App lässt sich nicht installieren
|
||||
|
||||
**Voraussetzungen:**
|
||||
1. HTTPS (oder localhost)
|
||||
2. Gültiges Manifest mit `name`, `icons`, `start_url`, `display`
|
||||
3. Service Worker registriert
|
||||
4. Icon mindestens 192x192px
|
||||
Loading…
Add table
Add a link
Reference in a new issue