diff --git a/package.json b/package.json index 9ef73d9..499c6e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.0.4", + "version": "1.0.5", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/public/sw.js b/public/sw.js index 8af599c..118917f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,10 @@ -const TILE_CACHE = 'lageplan-tiles-v2' -const STATIC_CACHE = 'lageplan-static-v2' -const APP_CACHE = 'lageplan-app-v2' +const TILE_CACHE = 'lageplan-tiles-v3' +const STATIC_CACHE = 'lageplan-static-v3' +const APP_CACHE = 'lageplan-app-v3' +const API_CACHE = 'lageplan-api-v3' + +// API routes that should be cached for offline use +const CACHEABLE_API = ['/api/icons', '/api/hose-types', '/api/dictionary'] // Pre-cache essential app shell on install self.addEventListener('install', (event) => { @@ -8,6 +12,8 @@ self.addEventListener('install', (event) => { caches.open(APP_CACHE).then((cache) => cache.addAll([ '/app', + '/login', + '/', '/logo.svg', '/logo-icon.png', '/manifest.json', @@ -17,7 +23,6 @@ self.addEventListener('install', (event) => { self.skipWaiting() }) -// Cache strategy: Network First for API, Cache First for tiles, Stale While Revalidate for static assets self.addEventListener('fetch', (event) => { const url = event.request.url const { pathname } = new URL(url) @@ -25,19 +30,68 @@ self.addEventListener('fetch', (event) => { // Skip non-GET requests if (event.request.method !== 'GET') return - // API requests: network only (don't cache dynamic data) + // Cacheable API routes: Network First with cache fallback (icons, hose-types, dictionary) + if (CACHEABLE_API.some(p => pathname.startsWith(p))) { + event.respondWith( + caches.open(API_CACHE).then((cache) => + fetch(event.request).then((response) => { + if (response.ok) cache.put(event.request, response.clone()) + return response + }).catch(() => + cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', { + status: 503, headers: { 'Content-Type': 'application/json' } + })) + ) + ) + ) + return + } + + // Projects API: Network First with cache fallback + if (pathname === '/api/projects' || pathname.match(/^\/api\/projects\/[^/]+$/)) { + event.respondWith( + caches.open(API_CACHE).then((cache) => + fetch(event.request).then((response) => { + if (response.ok) cache.put(event.request, response.clone()) + return response + }).catch(() => + cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', { + status: 503, headers: { 'Content-Type': 'application/json' } + })) + ) + ) + ) + return + } + + // Features API: Network First with cache fallback + if (pathname.match(/^\/api\/projects\/[^/]+\/features$/)) { + event.respondWith( + caches.open(API_CACHE).then((cache) => + fetch(event.request).then((response) => { + if (response.ok) cache.put(event.request, response.clone()) + return response + }).catch(() => + cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', { + status: 503, headers: { 'Content-Type': 'application/json' } + })) + ) + ) + ) + return + } + + // Other API requests: network only if (pathname.startsWith('/api/')) return - // Cache map tiles from OpenStreetMap (Cache First) + // Cache map tiles from OpenStreetMap / MapTiler (Cache First — tiles don't change) if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com')) { event.respondWith( caches.open(TILE_CACHE).then((cache) => cache.match(event.request).then((cached) => { if (cached) return cached return fetch(event.request).then((response) => { - if (response.ok) { - cache.put(event.request, response.clone()) - } + if (response.ok) cache.put(event.request, response.clone()) return response }).catch(() => new Response('', { status: 503 })) }) @@ -46,7 +100,23 @@ self.addEventListener('fetch', (event) => { return } - // Static assets (JS, CSS, images): Stale While Revalidate + // Next.js build chunks (_next/static): Cache First (hashed filenames = immutable) + if (pathname.startsWith('/_next/static/')) { + event.respondWith( + caches.open(STATIC_CACHE).then((cache) => + cache.match(event.request).then((cached) => { + if (cached) return cached + return fetch(event.request).then((response) => { + if (response.ok) cache.put(event.request, response.clone()) + return response + }).catch(() => new Response('', { status: 503 })) + }) + ) + ) + return + } + + // Other static assets (JS, CSS, images, fonts): Stale While Revalidate if (pathname.match(/\.(js|css|png|jpg|jpeg|svg|ico|woff2?)$/)) { event.respondWith( caches.open(STATIC_CACHE).then((cache) => @@ -62,8 +132,8 @@ self.addEventListener('fetch', (event) => { return } - // App pages: Network First with cache fallback - if (pathname === '/app' || pathname === '/' || pathname.startsWith('/app')) { + // App pages / navigation: Network First with cache fallback + if (event.request.mode === 'navigate' || pathname === '/app' || pathname === '/' || pathname.startsWith('/app')) { event.respondWith( fetch(event.request).then((response) => { if (response.ok) { @@ -81,7 +151,7 @@ self.addEventListener('fetch', (event) => { // Clean old caches on activation self.addEventListener('activate', (event) => { - const currentCaches = [TILE_CACHE, STATIC_CACHE, APP_CACHE] + const currentCaches = [TILE_CACHE, STATIC_CACHE, APP_CACHE, API_CACHE] event.waitUntil( caches.keys().then((keys) => Promise.all( @@ -92,3 +162,23 @@ self.addEventListener('activate', (event) => { ).then(() => self.clients.claim()) ) }) + +// Listen for sync events (Background Sync for queued saves) +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-saves') { + event.waitUntil(syncQueuedSaves()) + } +}) + +// Process queued saves from IndexedDB/localStorage +async function syncQueuedSaves() { + try { + const clients = await self.clients.matchAll() + clients.forEach(client => client.postMessage({ type: 'SYNC_START' })) + + // Read queue from a BroadcastChannel or let the main thread handle it + clients.forEach(client => client.postMessage({ type: 'FLUSH_SYNC_QUEUE' })) + } catch (e) { + console.error('[SW] Sync error:', e) + } +} diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index b10772d..2bbd55f 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -19,9 +19,10 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { JournalView } from '@/components/journal/journal-view' import { jsPDF } from 'jspdf' -import { Lock, Unlock, Eye, AlertTriangle } from 'lucide-react' +import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react' import { getSocket } from '@/lib/socket' import { CustomDragLayer } from '@/components/map/custom-drag-layer' +import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync' export interface Project { id: string @@ -366,6 +367,54 @@ export default function AppPage() { // Ref to access the map for export const mapRef = useRef(null) + // Offline detection + const [isOffline, setIsOffline] = useState(false) + const [syncQueueCount, setSyncQueueCount] = useState(0) + + useEffect(() => { + setIsOffline(!checkOnline()) + setSyncQueueCount(getSyncQueue().length) + + const goOffline = () => { + setIsOffline(true) + toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' }) + } + const goOnline = async () => { + setIsOffline(false) + const queue = getSyncQueue() + if (queue.length > 0) { + toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` }) + const result = await flushSyncQueue() + setSyncQueueCount(getSyncQueue().length) + if (result.success > 0) { + toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` }) + } + if (result.failed > 0) { + toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' }) + } + } else { + toast({ title: 'Wieder online' }) + } + } + + window.addEventListener('offline', goOffline) + window.addEventListener('online', goOnline) + + // Listen for SW sync messages + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.type === 'FLUSH_SYNC_QUEUE') { + flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length)) + } + }) + } + + return () => { + window.removeEventListener('offline', goOffline) + window.removeEventListener('online', goOnline) + } + }, []) + // Undo/Redo history const undoStackRef = useRef([]) const redoStackRef = useRef([]) @@ -643,11 +692,22 @@ export default function AppPage() { const saveTimerRef = useRef | null>(null) const saveFeaturesToApi = useCallback(async () => { if (!currentProject?.id) return + const url = `/api/projects/${currentProject.id}/features` + const body = { features: featuresRef.current } + + // If offline, queue the save for later sync + if (!navigator.onLine) { + addToSyncQueue(url, 'PUT', body) + setSyncQueueCount(getSyncQueue().length) + console.log('[Auto-Save] Offline — in Sync-Queue gespeichert') + return + } + try { - const res = await fetch(`/api/projects/${currentProject.id}/features`, { + const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ features: featuresRef.current }), + body: JSON.stringify(body), }) if (res.ok) { console.log('[Auto-Save] Features gespeichert') @@ -659,7 +719,10 @@ export default function AppPage() { console.warn('[Auto-Save] Projekt nicht in DB') } } catch (e) { - console.warn('[Auto-Save] Fehler:', e) + // Network error — queue for later + addToSyncQueue(url, 'PUT', body) + setSyncQueueCount(getSyncQueue().length) + console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e) } }, [currentProject]) @@ -1389,6 +1452,17 @@ export default function AppPage() { onLogout={logout} /> + {/* Offline banner */} + {isOffline && ( +
+ + + Offline-Modus — Änderungen werden lokal gespeichert und beim Reconnect synchronisiert. + {syncQueueCount > 0 && ` (${syncQueueCount} ausstehend)`} + +
+ )} + {/* Email verification banner */} {user && user.emailVerified === false && (
diff --git a/src/lib/offline-sync.ts b/src/lib/offline-sync.ts new file mode 100644 index 0000000..baac27f --- /dev/null +++ b/src/lib/offline-sync.ts @@ -0,0 +1,97 @@ +// Offline detection and sync queue for saving changes when reconnecting + +const SYNC_QUEUE_KEY = 'lageplan-sync-queue' + +interface SyncQueueItem { + id: string + url: string + method: string + body: string + timestamp: number +} + +/** Get all queued saves */ +export function getSyncQueue(): SyncQueueItem[] { + try { + const raw = localStorage.getItem(SYNC_QUEUE_KEY) + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } +} + +/** Add a save operation to the sync queue (called when offline) */ +export function addToSyncQueue(url: string, method: string, body: any): void { + const queue = getSyncQueue() + // Deduplicate: if same URL+method exists, replace it with newer data + const existing = queue.findIndex(q => q.url === url && q.method === method) + const item: SyncQueueItem = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + url, + method, + body: JSON.stringify(body), + timestamp: Date.now(), + } + if (existing >= 0) { + queue[existing] = item + } else { + queue.push(item) + } + localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(queue)) +} + +/** Flush the sync queue — send all queued requests to the server */ +export async function flushSyncQueue(): Promise<{ success: number; failed: number }> { + const queue = getSyncQueue() + if (queue.length === 0) return { success: 0, failed: 0 } + + let success = 0 + let failed = 0 + const remaining: SyncQueueItem[] = [] + + for (const item of queue) { + try { + const res = await fetch(item.url, { + method: item.method, + headers: { 'Content-Type': 'application/json' }, + body: item.body, + }) + if (res.ok) { + success++ + } else { + // Server error — keep in queue for retry + remaining.push(item) + failed++ + } + } catch { + // Still offline — keep in queue + remaining.push(item) + failed++ + } + } + + localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(remaining)) + return { success, failed } +} + +/** Clear the sync queue */ +export function clearSyncQueue(): void { + localStorage.removeItem(SYNC_QUEUE_KEY) +} + +/** Check if we're online */ +export function isOnline(): boolean { + return navigator.onLine +} + +/** Register Background Sync (if supported) */ +export async function registerBackgroundSync(): Promise { + if ('serviceWorker' in navigator && 'SyncManager' in window) { + try { + const reg = await navigator.serviceWorker.ready + await (reg as any).sync.register('sync-saves') + } catch { + // Background Sync not supported or failed — will use manual flush + } + } +}