diff --git a/apps/todo/apps/web/src/app.html b/apps/todo/apps/web/src/app.html index 77a5ff52c..076d6148e 100644 --- a/apps/todo/apps/web/src/app.html +++ b/apps/todo/apps/web/src/app.html @@ -1,9 +1,29 @@ - + - + + + + + + + + + + + + + + + + + + + + + %sveltekit.head% diff --git a/apps/todo/apps/web/static/icons/icon.svg b/apps/todo/apps/web/static/icons/icon.svg new file mode 100644 index 000000000..31ac90c22 --- /dev/null +++ b/apps/todo/apps/web/static/icons/icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/todo/apps/web/static/manifest.json b/apps/todo/apps/web/static/manifest.json new file mode 100644 index 000000000..5afff146a --- /dev/null +++ b/apps/todo/apps/web/static/manifest.json @@ -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 +} diff --git a/apps/todo/apps/web/static/offline.html b/apps/todo/apps/web/static/offline.html new file mode 100644 index 000000000..7b932041a --- /dev/null +++ b/apps/todo/apps/web/static/offline.html @@ -0,0 +1,228 @@ + + + + + + Offline - Todo + + + +
+
+ + + +
+ +

Du bist offline

+

Keine Internetverbindung. Sobald du wieder online bist, kannst du deine Aufgaben verwalten.

+ +
+ + Verbindung wird gesucht... +
+ + + +
+

Was du tun kannst

+ +
+
+ + + + diff --git a/apps/todo/apps/web/static/sw.js b/apps/todo/apps/web/static/sw.js new file mode 100644 index 000000000..7992ed8ec --- /dev/null +++ b/apps/todo/apps/web/static/sw.js @@ -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(); + } +}); diff --git a/docs/PWA_GUIDE.md b/docs/PWA_GUIDE.md new file mode 100644 index 000000000..20efe719a --- /dev/null +++ b/docs/PWA_GUIDE.md @@ -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 + + + + + + + + + + +``` + +### 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 + + + + + + Offline - App Name + + + +
+
+ + + +
+ +

Du bist offline

+

Keine Internetverbindung. Sobald du wieder online bist, kannst du die App nutzen.

+ +
+ + Verbindung wird gesucht... +
+ + +
+ + + + +``` + +--- + +## Schritt 5: PWA Meta-Tags + +### Datei: `src/app.html` + +Im `` hinzufügen: + +```html + + + + + + + + + + + + + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + +``` + +### Meta-Tags erklärt + +| Tag | Zweck | +|-----|-------| +| `` | Verlinkt das Web App Manifest | +| `` | 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} + +{/if} +``` + +### Update-Banner + +```svelte + + +{#if showUpdateBanner} +
+ Neue Version verfügbar + +
+{/if} +``` + +### Offline-Status anzeigen + +```svelte + + +{#if !isOnline} +
+ Offline +
+{/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. `` im `` 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