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>
This commit is contained in:
2026-02-07 23:00:34 -06:00
parent e88308a622
commit 36a53301a7
4 changed files with 78 additions and 10 deletions

View File

@@ -766,3 +766,10 @@ textarea,
select { select {
transition-property: all; transition-property: all;
} }
/* Offline mode label */
.offline-label {
font-size: 0.75rem;
color: var(--offline, #888);
font-weight: 600;
}

View File

@@ -44,6 +44,17 @@ const NextSnap = {
indicator.classList.add('offline'); indicator.classList.add('offline');
indicator.title = 'Offline'; indicator.title = 'Offline';
} }
// Show/hide "Offline Mode" label
const offlineLabel = document.getElementById('offlineLabel');
if (offlineLabel) {
offlineLabel.style.display = this.isOnline ? 'none' : '';
}
// Show/hide online-only nav items (Files, Admin)
document.querySelectorAll('[data-online-only]').forEach(el => {
el.style.display = this.isOnline ? '' : 'none';
});
}, },
setSyncingStatus(isSyncing) { setSyncingStatus(isSyncing) {

View File

@@ -1,27 +1,54 @@
// NextSnap Service Worker // NextSnap Service Worker
// Provides offline-first caching for the app shell // Provides offline-first caching for the app shell
const CACHE_VERSION = 'nextsnap-v22'; const CACHE_VERSION = 'nextsnap-v25';
const APP_SHELL_CACHE = 'nextsnap-shell-v18'; const APP_SHELL_CACHE = 'nextsnap-shell-v21';
const RUNTIME_CACHE = 'nextsnap-runtime-v18'; const RUNTIME_CACHE = 'nextsnap-runtime-v21';
// Offline fallback page (served when network fails and no cached version exists) // Offline fallback page with bottom nav bar so user can navigate to cached pages
const OFFLINE_PAGE = `<!DOCTYPE html> const OFFLINE_PAGE = `<!DOCTYPE html>
<html lang="en"><head> <html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <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> <title>Offline - NextSnap</title>
<style> <style>
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0; body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;
display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center} 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} h1{font-size:1.5rem;margin-bottom:1rem;color:#fff}
p{color:#aaa;margin-bottom:1.5rem;line-height:1.5} 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; button{background:#4dabf7;color:#fff;border:none;padding:0.75rem 2rem;border-radius:8px;
font-size:1rem;cursor:pointer;min-height:44px} font-size:1rem;cursor:pointer;min-height:44px}
button:active{opacity:0.8} 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> </style></head><body>
<div><h1>You're Offline</h1><p>Check your internet connection and try again.</p> <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> <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>`; </body></html>`;
// Assets to cache on install // Assets to cache on install
@@ -73,6 +100,16 @@ self.addEventListener('activate', (event) => {
}) })
); );
}) })
.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(() => { .then(() => {
console.log('[SW] Service worker activated'); console.log('[SW] Service worker activated');
return self.clients.claim(); // Take control immediately return self.clients.claim(); // Take control immediately
@@ -115,12 +152,17 @@ self.addEventListener('fetch', (event) => {
return; 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 // Everything else (pages, API) - network-first with cache fallback
event.respondWith( event.respondWith(
fetch(request) fetch(request)
.then((response) => { .then((response) => {
// Cache successful GET responses for offline fallback // Cache successful GET responses for offline fallback
if (response.status === 200) { // Skip online-only pages - they need live data
if (response.status === 200 && !isOnlineOnly) {
const responseClone = response.clone(); const responseClone = response.clone();
caches.open(RUNTIME_CACHE).then((cache) => { caches.open(RUNTIME_CACHE).then((cache) => {
// Don't cache file uploads or large binary responses // Don't cache file uploads or large binary responses
@@ -133,6 +175,13 @@ self.addEventListener('fetch', (event) => {
return response; return response;
}) })
.catch(() => { .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 // Network failed - try cache
return caches.match(request) return caches.match(request)
.then((cachedResponse) => { .then((cachedResponse) => {

View File

@@ -25,6 +25,7 @@
<h1 class="app-title">NextSnap</h1> <h1 class="app-title">NextSnap</h1>
<div class="top-bar-indicators"> <div class="top-bar-indicators">
<div class="connectivity-indicator online" title="Online"></div> <div class="connectivity-indicator online" title="Online"></div>
<span class="offline-label" id="offlineLabel" style="display:none">Offline Mode</span>
<div class="pending-count" id="pendingCount" style="display: none;"> <div class="pending-count" id="pendingCount" style="display: none;">
<span id="pendingCountValue">0</span> <span id="pendingCountValue">0</span>
</div> </div>
@@ -48,12 +49,12 @@
<span class="nav-icon">📤</span> <span class="nav-icon">📤</span>
<span class="nav-label">Queue</span> <span class="nav-label">Queue</span>
</a> </a>
<a href="/browser" class="nav-item {% if request.path == '/browser' %}active{% endif %}"> <a href="/browser" class="nav-item {% if request.path == '/browser' %}active{% endif %}" data-online-only>
<span class="nav-icon">📁</span> <span class="nav-icon">📁</span>
<span class="nav-label">Files</span> <span class="nav-label">Files</span>
</a> </a>
{% if is_admin %} {% if is_admin %}
<a href="/admin" class="nav-item {% if request.path == '/admin' %}active{% endif %}"> <a href="/admin" class="nav-item {% if request.path == '/admin' %}active{% endif %}" data-online-only>
<span class="nav-icon">⚙️</span> <span class="nav-icon">⚙️</span>
<span class="nav-label">Admin</span> <span class="nav-label">Admin</span>
</a> </a>