fix: mobile indicator visibility, sequential pre-cache, and verified ready state
This commit is contained in:
84
public/sw.js
84
public/sw.js
@@ -1,8 +1,8 @@
|
|||||||
const CACHE_NAME = 'whisky-vault-v8-bunker'; // Optimierter Bunker v8
|
const CACHE_NAME = 'whisky-vault-v10-bunker'; // Professional Bunker v10
|
||||||
|
|
||||||
// CONFIG: Kern-Seiten und Assets für den Bunker (sofortiges Pre-Caching)
|
// CONFIG: Core Pages & Static Assets
|
||||||
const CORE_PAGES = [
|
const CORE_PAGES = [
|
||||||
'/', // Dashboard / App Shell (Enthält Scan & Tasting)
|
'/',
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -12,12 +12,25 @@ const STATIC_ASSETS = [
|
|||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper: Fetch mit Timeout und Sauberen Abort
|
// Helper: Broadcast to all clients (including those not yet controlled)
|
||||||
async function fetchWithTimeout(request, timeoutMs = 3000) {
|
async function broadcast(message) {
|
||||||
|
try {
|
||||||
|
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
|
||||||
|
clients.forEach(client => client.postMessage(message));
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail if no clients found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Fetch with Timeout & AbortController
|
||||||
|
async function fetchWithTimeout(url, timeoutMs = 15000) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request, { signal: controller.signal });
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store' // Ensure we get fresh bits for the bunker
|
||||||
|
});
|
||||||
clearTimeout(id);
|
clearTimeout(id);
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -26,27 +39,25 @@ async function fetchWithTimeout(request, timeoutMs = 3000) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Broadcast an alle Clients
|
// 🏗️ INSTALL: Build the Bunker
|
||||||
async function broadcast(message) {
|
|
||||||
const clients = await self.clients.matchAll();
|
|
||||||
clients.forEach(client => client.postMessage(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install: Lade alles Wichtige einzeln in den Bunker
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
// Immediate takeover
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then(async (cache) => {
|
caches.open(CACHE_NAME).then(async (cache) => {
|
||||||
console.log('🏗️ PWA: Building bunker v8...');
|
console.log('🏗️ PWA: Building Bunker v10...');
|
||||||
const total = CORE_PAGES.length + STATIC_ASSETS.length;
|
const items = [...CORE_PAGES, ...STATIC_ASSETS];
|
||||||
|
const total = items.length;
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
|
|
||||||
const promises = [...CORE_PAGES, ...STATIC_ASSETS].map(async (url) => {
|
// Sequential loading to avoid concurrent fetch limits on mobile
|
||||||
|
for (const url of items) {
|
||||||
try {
|
try {
|
||||||
// Im Dev-Mode kann / lange dauern (Kompilierung). 10s Timeout.
|
const res = await fetchWithTimeout(url);
|
||||||
const res = await fetchWithTimeout(url, 10000);
|
if (res && res.ok) {
|
||||||
if (!res.ok) throw new Error(`Status ${res.status}`);
|
await cache.put(url, res);
|
||||||
await cache.put(url, res);
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error);
|
console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,18 +67,15 @@ self.addEventListener('install', (event) => {
|
|||||||
progress: Math.round((loaded / total) * 100)
|
progress: Math.round((loaded / total) * 100)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
console.log('✅ PWA: Bunker build finished');
|
console.log('✅ PWA: Bunker build finished');
|
||||||
|
|
||||||
// Signal an Clients: Bunker ist bereit
|
|
||||||
broadcast({ type: 'PRECACHE_COMPLETE', version: CACHE_NAME });
|
broadcast({ type: 'PRECACHE_COMPLETE', version: CACHE_NAME });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate: Alte Bunker räumen
|
// 🧹 ACTIVATE: Cleanup old bunkers
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
@@ -84,7 +92,7 @@ self.addEventListener('activate', (event) => {
|
|||||||
self.clients.claim();
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Communication: Listen for status checks
|
// 💬 MESSAGE: Handle status checks
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'CHECK_BUNKER_STATUS') {
|
if (event.data?.type === 'CHECK_BUNKER_STATUS') {
|
||||||
event.source.postMessage({
|
event.source.postMessage({
|
||||||
@@ -95,36 +103,34 @@ self.addEventListener('message', (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🚀 FETCH: Bunker Strategy
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// Nur GET-Requests cachen
|
|
||||||
if (event.request.method !== 'GET') return;
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
// 0. BYPASS: Auth & API (wird von App/Dexie direkt gehandelt)
|
// 0. BYPASS for Auth/API/Supabase
|
||||||
if (url.pathname.includes('/auth/') ||
|
if (url.pathname.includes('/auth/') ||
|
||||||
url.pathname.includes('/api/') ||
|
url.pathname.includes('/api/') ||
|
||||||
url.hostname.includes('supabase.co')) {
|
url.hostname.includes('supabase.co')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. NEXT.JS DATA (RSC): Bunker & Fallback
|
// 1. RSC DATA (_next/data): Bunker with JSON Fallback
|
||||||
if (url.pathname.startsWith('/_next/data/')) {
|
if (url.pathname.startsWith('/_next/data/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
caches.match(event.request).then((cached) => {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
return fetchWithTimeout(event.request, 2000)
|
return fetchWithTimeout(event.request, 3000)
|
||||||
.catch(() => {
|
.catch(() => new Response(JSON.stringify({}), {
|
||||||
return new Response(JSON.stringify({}), {
|
headers: { 'Content-Type': 'application/json' }
|
||||||
headers: { 'Content-Type': 'application/json' }
|
}));
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ASSETS & NAVIGATION: Stale-While-Revalidate (Der "Echte" Bunker Mode)
|
// 2. NAVIGATION & ASSETS: Bunker + SWR
|
||||||
const isNavigation = event.request.mode === 'navigate';
|
const isNavigation = event.request.mode === 'navigate';
|
||||||
const isAsset = event.request.destination === 'style' ||
|
const isAsset = event.request.destination === 'style' ||
|
||||||
event.request.destination === 'script' ||
|
event.request.destination === 'script' ||
|
||||||
@@ -136,8 +142,8 @@ self.addEventListener('fetch', (event) => {
|
|||||||
if (isNavigation || isAsset) {
|
if (isNavigation || isAsset) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then(async (cachedResponse) => {
|
caches.match(event.request).then(async (cachedResponse) => {
|
||||||
// Hintergrund-Update
|
// Background update
|
||||||
const fetchPromise = fetchWithTimeout(event.request, 5000)
|
const fetchPromise = fetchWithTimeout(event.request, 8000)
|
||||||
.then(async (networkResponse) => {
|
.then(async (networkResponse) => {
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
@@ -145,7 +151,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
}
|
}
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
})
|
})
|
||||||
.catch(() => { /* Silent in background */ });
|
.catch(() => { /* Background fail silent */ });
|
||||||
|
|
||||||
if (isNavigation) {
|
if (isNavigation) {
|
||||||
if (cachedResponse) return cachedResponse;
|
if (cachedResponse) return cachedResponse;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export default function OfflineIndicator() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOffline(!navigator.onLine);
|
setIsOffline(!navigator.onLine);
|
||||||
// Check if bunker was already ready from previous session
|
|
||||||
const savedReady = localStorage.getItem('whisky_bunker_ready') === 'true';
|
const savedReady = localStorage.getItem('whisky_bunker_ready') === 'true';
|
||||||
setIsBunkerReady(savedReady);
|
setIsBunkerReady(savedReady);
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ export default function OfflineIndicator() {
|
|||||||
setProgress(event.data.progress);
|
setProgress(event.data.progress);
|
||||||
}
|
}
|
||||||
if (event.data?.type === 'PRECACHE_COMPLETE' || event.data?.type === 'BUNKER_STATUS') {
|
if (event.data?.type === 'PRECACHE_COMPLETE' || event.data?.type === 'BUNKER_STATUS') {
|
||||||
console.log('🛡️ PWA: Bunker is ready for offline use!');
|
console.log('🛡️ PWA: Bunker is ready!');
|
||||||
setIsBunkerReady(true);
|
setIsBunkerReady(true);
|
||||||
localStorage.setItem('whisky_bunker_ready', 'true');
|
localStorage.setItem('whisky_bunker_ready', 'true');
|
||||||
}
|
}
|
||||||
@@ -34,27 +33,10 @@ export default function OfflineIndicator() {
|
|||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.addEventListener('message', handleMessage);
|
navigator.serviceWorker.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
// Proactive check
|
// Initial check: if already active, ask for status
|
||||||
navigator.serviceWorker.ready.then((registration) => {
|
if (navigator.serviceWorker.controller) {
|
||||||
if (registration.active) {
|
navigator.serviceWorker.controller.postMessage({ type: 'CHECK_BUNKER_STATUS' });
|
||||||
registration.active.postMessage({ type: 'CHECK_BUNKER_STATUS' });
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fallback: If after 20s we still think we are loading but SW is active, assume ready
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const isSwActive = !!navigator.serviceWorker.controller;
|
|
||||||
if (!isBunkerReady && isSwActive) {
|
|
||||||
setIsBunkerReady(true);
|
|
||||||
localStorage.setItem('whisky_bunker_ready', 'true');
|
|
||||||
}
|
|
||||||
}, 20000);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
window.removeEventListener('online', handleOnline);
|
|
||||||
window.removeEventListener('offline', handleOffline);
|
|
||||||
navigator.serviceWorker.removeEventListener('message', handleMessage);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -64,41 +46,62 @@ export default function OfflineIndicator() {
|
|||||||
navigator.serviceWorker.removeEventListener('message', handleMessage);
|
navigator.serviceWorker.removeEventListener('message', handleMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isBunkerReady]);
|
}, []);
|
||||||
|
|
||||||
|
// 1. OFFLINE BAR (TOP)
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 w-full bg-red-600 text-white text-[10px] font-black uppercase tracking-widest py-1 flex items-center justify-center gap-2 z-[9999] animate-pulse">
|
<div className="fixed top-0 left-0 w-full bg-red-600 text-white text-[11px] font-black uppercase tracking-[0.2em] py-2 flex items-center justify-center gap-2 z-[10001] shadow-xl animate-in slide-in-from-top duration-300">
|
||||||
<WifiOff size={12} />
|
<WifiOff size={14} className="animate-pulse" />
|
||||||
Offline-Modus: Bunker aktiv 🛡️
|
Offline-Modus: Bunker aktiv 🛡️
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. READY STATUS (BOTTOM RIGHT)
|
||||||
if (isBunkerReady) {
|
if (isBunkerReady) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-20 right-4 z-[90] animate-in fade-in slide-in-from-right-4 duration-500">
|
<div className="fixed bottom-24 right-4 z-[10000] animate-in fade-in slide-in-from-right-10 duration-700 pointer-events-auto">
|
||||||
<div className="bg-zinc-900/80 backdrop-blur-md border border-green-500/30 px-3 py-1.5 rounded-full flex items-center gap-2 shadow-lg shadow-green-500/10 group hover:bg-zinc-900 transition-colors cursor-help">
|
<div className="bg-zinc-900/95 backdrop-blur-xl border border-green-500/40 px-4 py-2.5 rounded-2xl flex items-center gap-2.5 shadow-[0_20px_50px_rgba(0,0,0,0.5),0_0_20px_rgba(34,197,94,0.1)] group hover:scale-105 transition-all cursor-help ring-1 ring-white/10">
|
||||||
<ShieldCheck size={14} className="text-green-500" />
|
<div className="relative">
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest text-zinc-300">
|
<ShieldCheck size={18} className="text-green-500" />
|
||||||
Bunker Aktiv
|
<div className="absolute -top-1 -right-1 w-2 h-2 bg-green-500 rounded-full blur-[2px] animate-pulse" />
|
||||||
</span>
|
</div>
|
||||||
<div className="absolute bottom-full right-0 mb-2 w-48 p-2 bg-zinc-900 text-[10px] text-zinc-400 rounded-xl border border-white/10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none shadow-2xl">
|
<div className="flex flex-col">
|
||||||
Die App ist vollständig im "Bunker" gespeichert und funktioniert auch ohne Internet.
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white">Bunker Aktiv</span>
|
||||||
|
<span className="text-[8px] font-bold text-zinc-500 uppercase tracking-widest">Vollständig Offline fähig</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full right-0 mb-3 w-56 p-3 bg-zinc-950 text-[10px] text-zinc-400 rounded-2xl border border-white/10 opacity-0 group-hover:opacity-100 transition-all pointer-events-none shadow-2xl translate-y-2 group-hover:translate-y-0">
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
<strong className="text-white block mb-1">Status: Gesichert</strong>
|
||||||
|
Alle wichtigen Bestandteile der App sind lokal gespeichert. Du kannst die App jederzeit im Funkloch nutzen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show nothing if online and not ready yet (or show a loading state?)
|
// 3. LOADING STATUS (BOTTOM RIGHT)
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-20 right-4 z-[90] animate-in fade-in slide-in-from-right-4">
|
<div className="fixed bottom-24 right-4 z-[10000] animate-in fade-in slide-in-from-right-10 duration-500 pointer-events-auto">
|
||||||
<div className="bg-zinc-900/80 backdrop-blur-md border border-amber-500/30 px-3 py-1.5 rounded-full flex items-center gap-2 shadow-lg shadow-amber-500/10">
|
<div className="bg-zinc-900/95 backdrop-blur-xl border border-amber-500/40 px-4 py-2.5 rounded-2xl flex items-center gap-3 shadow-[0_20px_50px_rgba(0,0,0,0.5),0_0_20px_rgba(245,158,11,0.1)] ring-1 ring-white/10">
|
||||||
<div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" />
|
<div className="relative w-5 h-5 flex items-center justify-center">
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest text-zinc-300">
|
<div className="absolute inset-0 border-2 border-amber-500/20 rounded-full" />
|
||||||
Bunker wird geladen... {progress > 0 ? `${progress}%` : ''}
|
<div
|
||||||
</span>
|
className="absolute inset-0 border-2 border-amber-500 rounded-full border-t-transparent animate-spin"
|
||||||
|
style={{ borderRightColor: 'transparent' }}
|
||||||
|
/>
|
||||||
|
<div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white">Bunker lädt...</span>
|
||||||
|
<span className="text-[10px] font-black text-amber-500 tabular-nums">
|
||||||
|
{progress > 0 ? `${progress}%` : 'Initialisiere'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user