Files
nextsnap/app/static/sw.js
kamaji 36a53301a7 Show offline mode UI: hide online-only nav items, show offline label
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>
2026-02-07 23:00:34 -06:00

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))
);
})
);
}
});