When offline, hide Files and Admin from the bottom nav and display "Offline Mode" next to the status dot. The SW offline fallback page also only shows Capture and Queue nav items. Online-only pages (browser, admin, reviewer) are never cached and go straight to the offline fallback when the network is unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
8.9 KiB
JavaScript
223 lines
8.9 KiB
JavaScript
// NextSnap Service Worker
|
|
// Provides offline-first caching for the app shell
|
|
|
|
const CACHE_VERSION = 'nextsnap-v25';
|
|
const APP_SHELL_CACHE = 'nextsnap-shell-v21';
|
|
const RUNTIME_CACHE = 'nextsnap-runtime-v21';
|
|
|
|
// Offline fallback page with bottom nav bar so user can navigate to cached pages
|
|
const OFFLINE_PAGE = `<!DOCTYPE html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
|
<meta name="theme-color" content="#16213e">
|
|
<title>Offline - NextSnap</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;
|
|
display:flex;flex-direction:column;min-height:100vh}
|
|
.top-bar{background:#16213e;padding:0.75rem 1rem;border-bottom:1px solid rgba(255,255,255,0.1);
|
|
display:flex;align-items:center;justify-content:space-between}
|
|
.app-title{font-size:1.2rem;color:#fff;font-weight:700}
|
|
.top-bar-right{display:flex;align-items:center;gap:0.5rem}
|
|
.status-dot{width:12px;height:12px;border-radius:50%;background:#888}
|
|
.offline-label{font-size:0.75rem;color:#888;font-weight:600}
|
|
.offline-content{flex:1;display:flex;align-items:center;justify-content:center;
|
|
padding:2rem;text-align:center}
|
|
h1{font-size:1.5rem;margin-bottom:1rem;color:#fff}
|
|
p{color:#aaa;margin-bottom:1.5rem;line-height:1.5}
|
|
button{background:#4dabf7;color:#fff;border:none;padding:0.75rem 2rem;border-radius:8px;
|
|
font-size:1rem;cursor:pointer;min-height:44px}
|
|
button:active{opacity:0.8}
|
|
.bottom-nav{position:fixed;bottom:0;left:0;right:0;background:#1e2a4a;
|
|
border-top:1px solid rgba(255,255,255,0.1);display:flex;justify-content:space-around;
|
|
z-index:1000;padding-bottom:env(safe-area-inset-bottom)}
|
|
.nav-item{flex:1;display:flex;flex-direction:column;align-items:center;padding:0.5rem 0;
|
|
text-decoration:none;color:#8899aa;min-height:56px;justify-content:center}
|
|
.nav-icon{font-size:1.5rem}
|
|
.nav-label{font-size:0.7rem;margin-top:0.25rem}
|
|
</style></head><body>
|
|
<header class="top-bar">
|
|
<h1 class="app-title">NextSnap</h1>
|
|
<div class="top-bar-right"><div class="status-dot"></div><span class="offline-label">Offline Mode</span></div>
|
|
</header>
|
|
<div class="offline-content">
|
|
<div><h1>You're Offline</h1>
|
|
<p>This page requires an internet connection.<br>Capture and Queue are available offline.</p>
|
|
<button onclick="location.reload()">Retry</button></div>
|
|
</div>
|
|
<nav class="bottom-nav">
|
|
<a href="/capture" class="nav-item"><span class="nav-icon">📷</span><span class="nav-label">Capture</span></a>
|
|
<a href="/queue" class="nav-item"><span class="nav-icon">📤</span><span class="nav-label">Queue</span></a>
|
|
</nav>
|
|
</body></html>`;
|
|
|
|
// Assets to cache on install
|
|
const APP_SHELL_ASSETS = [
|
|
'/static/css/style.css',
|
|
'/static/js/app.js',
|
|
'/static/js/auth.js',
|
|
'/static/js/camera.js',
|
|
'/static/js/storage.js',
|
|
'/static/js/sync.js',
|
|
'/static/js/filebrowser.js',
|
|
'/static/lib/dexie.min.js',
|
|
'/static/manifest.json',
|
|
'/static/icons/icon-192.png',
|
|
'/static/icons/icon-512.png'
|
|
];
|
|
|
|
// Install event - precache app shell
|
|
self.addEventListener('install', (event) => {
|
|
console.log('[SW] Installing service worker...');
|
|
event.waitUntil(
|
|
caches.open(APP_SHELL_CACHE)
|
|
.then((cache) => {
|
|
console.log('[SW] Caching app shell');
|
|
return cache.addAll(APP_SHELL_ASSETS);
|
|
})
|
|
.then(() => {
|
|
console.log('[SW] App shell cached successfully');
|
|
return self.skipWaiting(); // Activate immediately
|
|
})
|
|
.catch((error) => {
|
|
console.error('[SW] Failed to cache app shell:', error);
|
|
})
|
|
);
|
|
});
|
|
|
|
// Activate event - clean up old caches
|
|
self.addEventListener('activate', (event) => {
|
|
console.log('[SW] Activating service worker...');
|
|
event.waitUntil(
|
|
caches.keys()
|
|
.then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames.map((cacheName) => {
|
|
if (cacheName !== APP_SHELL_CACHE && cacheName !== RUNTIME_CACHE) {
|
|
console.log('[SW] Deleting old cache:', cacheName);
|
|
return caches.delete(cacheName);
|
|
}
|
|
})
|
|
);
|
|
})
|
|
.then(() => {
|
|
// Purge cached online-only pages so they show offline fallback
|
|
return caches.open(RUNTIME_CACHE).then((cache) => {
|
|
return Promise.all(
|
|
['/browser', '/admin', '/reviewer'].map(path =>
|
|
cache.delete(new Request(path)).catch(() => {})
|
|
)
|
|
);
|
|
});
|
|
})
|
|
.then(() => {
|
|
console.log('[SW] Service worker activated');
|
|
return self.clients.claim(); // Take control immediately
|
|
})
|
|
);
|
|
});
|
|
|
|
// Fetch event - route requests to appropriate caching strategy
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Skip non-GET requests (uploads, form posts, etc.)
|
|
if (request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Static assets (/static/) - cache-first (versioned via SW cache bump)
|
|
if (url.pathname.startsWith('/static/')) {
|
|
event.respondWith(
|
|
caches.match(request)
|
|
.then((cachedResponse) => {
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
return fetch(request)
|
|
.then((response) => {
|
|
if (!response || response.status !== 200) {
|
|
return response;
|
|
}
|
|
const responseClone = response.clone();
|
|
caches.open(APP_SHELL_CACHE).then((cache) => {
|
|
cache.put(request, responseClone);
|
|
});
|
|
return response;
|
|
})
|
|
.catch(() => new Response('Offline', { status: 503 }));
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Pages that require connectivity - never cache, always show offline page
|
|
const ONLINE_ONLY = ['/browser', '/admin', '/reviewer'];
|
|
const isOnlineOnly = ONLINE_ONLY.includes(url.pathname);
|
|
|
|
// Everything else (pages, API) - network-first with cache fallback
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
// Cache successful GET responses for offline fallback
|
|
// Skip online-only pages - they need live data
|
|
if (response.status === 200 && !isOnlineOnly) {
|
|
const responseClone = response.clone();
|
|
caches.open(RUNTIME_CACHE).then((cache) => {
|
|
// Don't cache file uploads or large binary responses
|
|
if (!url.pathname.includes('/upload') &&
|
|
!url.pathname.includes('/thumbnail')) {
|
|
cache.put(request, responseClone);
|
|
}
|
|
});
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
// Online-only pages go straight to offline fallback
|
|
if (isOnlineOnly) {
|
|
return new Response(OFFLINE_PAGE, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html' }
|
|
});
|
|
}
|
|
// Network failed - try cache
|
|
return caches.match(request)
|
|
.then((cachedResponse) => {
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
// No cache - return offline response
|
|
if (url.pathname.startsWith('/api/')) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'offline', message: 'You are offline.' }),
|
|
{ status: 503, headers: { 'Content-Type': 'application/json' } }
|
|
);
|
|
}
|
|
return new Response(OFFLINE_PAGE, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html' }
|
|
});
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// Listen for messages from clients
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting();
|
|
}
|
|
|
|
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
|
event.waitUntil(
|
|
caches.keys().then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames.map((cacheName) => caches.delete(cacheName))
|
|
);
|
|
})
|
|
);
|
|
}
|
|
});
|