Complete platform with unified design system and real API integration. Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings Infrastructure: SvelteKit + Python gateway + Docker Compose
650 lines
20 KiB
JavaScript
650 lines
20 KiB
JavaScript
const CACHE_NAME = 'trips-v17';
|
|
const STATIC_CACHE = 'trips-static-v17';
|
|
const DATA_CACHE = 'trips-data-v17';
|
|
const IMAGE_CACHE = 'trips-images-v17';
|
|
|
|
// Static assets to cache on install
|
|
const STATIC_ASSETS = [
|
|
'/'
|
|
];
|
|
|
|
// Install event - cache static assets
|
|
self.addEventListener('install', (event) => {
|
|
console.log('[SW] Installing service worker...');
|
|
event.waitUntil(
|
|
caches.open(STATIC_CACHE)
|
|
.then((cache) => {
|
|
console.log('[SW] Caching static assets');
|
|
return cache.addAll(STATIC_ASSETS);
|
|
})
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
// 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) => {
|
|
// Delete old version caches
|
|
if (cacheName.startsWith('trips-') &&
|
|
cacheName !== STATIC_CACHE &&
|
|
cacheName !== DATA_CACHE &&
|
|
cacheName !== IMAGE_CACHE) {
|
|
console.log('[SW] Deleting old cache:', cacheName);
|
|
return caches.delete(cacheName);
|
|
}
|
|
})
|
|
);
|
|
}).then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
// Check if response is valid (not error page, not Cloudflare error)
|
|
function isValidResponse(response) {
|
|
if (!response) return false;
|
|
if (!response.ok) return false;
|
|
if (response.redirected) return false;
|
|
if (response.status !== 200) return false;
|
|
|
|
// Check content-type to detect error pages
|
|
const contentType = response.headers.get('content-type') || '';
|
|
|
|
// If we expect HTML but got something else, it might be an error
|
|
return true;
|
|
}
|
|
|
|
// Check if response is a Cloudflare/proxy error page
|
|
async function isErrorPage(response) {
|
|
if (!response) return true;
|
|
if (response.status >= 500) return true;
|
|
if (response.status === 0) return true;
|
|
|
|
// Clone to read body without consuming
|
|
const clone = response.clone();
|
|
try {
|
|
const text = await clone.text();
|
|
// Detect common error page signatures
|
|
if (text.includes('cloudflare') && text.includes('Error')) return true;
|
|
if (text.includes('502 Bad Gateway')) return true;
|
|
if (text.includes('503 Service')) return true;
|
|
if (text.includes('504 Gateway')) return true;
|
|
} catch (e) {
|
|
// If we can't read it, assume it's fine
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Fetch event - serve from cache, fallback to network
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (event.request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Never cache auth pages - always go to network
|
|
if (url.pathname === '/login' || url.pathname === '/logout' || url.pathname.startsWith('/auth')) {
|
|
return;
|
|
}
|
|
|
|
// Handle CDN requests (Quill, etc.) - cache first
|
|
if (url.origin !== location.origin) {
|
|
if (url.hostname.includes('cdn.jsdelivr.net')) {
|
|
event.respondWith(
|
|
caches.match(event.request).then(cached => {
|
|
return cached || fetch(event.request);
|
|
})
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle API requests
|
|
if (url.pathname.startsWith('/api/')) {
|
|
event.respondWith(handleApiRequest(event.request));
|
|
return;
|
|
}
|
|
|
|
// Handle image requests
|
|
if (url.pathname.startsWith('/images/')) {
|
|
event.respondWith(handleImageRequest(event.request));
|
|
return;
|
|
}
|
|
|
|
// Handle document requests
|
|
if (url.pathname.startsWith('/documents/')) {
|
|
event.respondWith(handleDocumentRequest(event.request));
|
|
return;
|
|
}
|
|
|
|
// Handle trip pages - CACHE FIRST for offline support
|
|
if (url.pathname.startsWith('/trip/')) {
|
|
event.respondWith(handleTripPageRequest(event.request));
|
|
return;
|
|
}
|
|
|
|
// Handle main page - cache first, then pre-cache all trips
|
|
if (url.pathname === '/') {
|
|
event.respondWith(handleMainPageRequest(event.request));
|
|
return;
|
|
}
|
|
|
|
// Default: cache first, network fallback
|
|
event.respondWith(handleStaticRequest(event.request));
|
|
});
|
|
|
|
// Handle main page - NETWORK FIRST, only cache when offline
|
|
async function handleMainPageRequest(request) {
|
|
// Try network first
|
|
try {
|
|
const response = await fetch(request);
|
|
|
|
// If redirected (to login), clear cache and return the redirect
|
|
if (response.redirected) {
|
|
console.log('[SW] Main page redirected (likely to login), clearing cache');
|
|
const cache = await caches.open(STATIC_CACHE);
|
|
cache.delete(request);
|
|
return response;
|
|
}
|
|
|
|
if (response.ok) {
|
|
// Clone to read HTML and cache main page images
|
|
const htmlText = await response.clone().text();
|
|
|
|
const cache = await caches.open(STATIC_CACHE);
|
|
cache.put(request, response.clone());
|
|
|
|
// Cache main page images in background
|
|
cacheMainPageImages(htmlText);
|
|
|
|
return response;
|
|
}
|
|
|
|
// Non-OK response, just return it
|
|
return response;
|
|
} catch (error) {
|
|
// Network failed - we're offline, try cache
|
|
console.log('[SW] Main page network failed, trying cache');
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) return cachedResponse;
|
|
return createOfflineResponse('Main page not available offline');
|
|
}
|
|
}
|
|
|
|
// Cache images from main page
|
|
async function cacheMainPageImages(html) {
|
|
const imageCache = await caches.open(IMAGE_CACHE);
|
|
const imageUrls = extractUrls(html, /\/images\/[^"'\s<>]+/g);
|
|
|
|
for (const imgUrl of imageUrls) {
|
|
try {
|
|
const existing = await caches.match(imgUrl);
|
|
if (!existing) {
|
|
const imgResponse = await fetch(imgUrl);
|
|
if (imgResponse.ok) {
|
|
await imageCache.put(imgUrl, imgResponse);
|
|
console.log('[SW] Cached main page image:', imgUrl);
|
|
}
|
|
}
|
|
} catch (e) { /* skip */ }
|
|
}
|
|
}
|
|
|
|
// Handle trip pages - NETWORK FIRST, fall back to cache only when offline
|
|
async function handleTripPageRequest(request) {
|
|
// Always try network first
|
|
try {
|
|
console.log('[SW] Fetching trip from network:', request.url);
|
|
const response = await fetch(request);
|
|
|
|
// Check if response is valid
|
|
if (response.ok && response.status === 200) {
|
|
// Check if we got redirected to login/auth page (auth failure)
|
|
const finalUrl = response.url || '';
|
|
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) {
|
|
console.log('[SW] Redirected to auth, not caching:', finalUrl);
|
|
// Clear any cached version of this page since user is logged out
|
|
const cache = await caches.open(DATA_CACHE);
|
|
cache.delete(request);
|
|
return response;
|
|
}
|
|
|
|
// Cache and return the valid response
|
|
const cache = await caches.open(DATA_CACHE);
|
|
cache.put(request, response.clone());
|
|
console.log('[SW] Cached trip page:', request.url);
|
|
return response;
|
|
}
|
|
|
|
// Non-200 response, just return it (might be login redirect)
|
|
console.log('[SW] Non-200 response:', response.status, request.url);
|
|
return response;
|
|
} catch (error) {
|
|
// Network failed - we're offline, try cache
|
|
console.log('[SW] Network failed, trying cache for trip:', request.url);
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
console.log('[SW] Serving trip from cache (offline):', request.url);
|
|
// Add header to indicate offline mode
|
|
const headers = new Headers(cachedResponse.headers);
|
|
headers.set('X-From-Cache', 'true');
|
|
return new Response(cachedResponse.body, {
|
|
status: cachedResponse.status,
|
|
statusText: cachedResponse.statusText,
|
|
headers: headers
|
|
});
|
|
}
|
|
return createOfflineResponse('Trip not available offline. Download this trip while online first.');
|
|
}
|
|
}
|
|
|
|
// Update trip cache in background
|
|
async function updateTripCache(request) {
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok && response.status === 200) {
|
|
// Skip if redirected to login/auth
|
|
const finalUrl = response.url || '';
|
|
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) return;
|
|
|
|
const cache = await caches.open(DATA_CACHE);
|
|
cache.put(request, response.clone());
|
|
console.log('[SW] Updated cache for:', request.url);
|
|
}
|
|
} catch (error) {
|
|
// Silent fail - we already served from cache
|
|
}
|
|
}
|
|
|
|
// Send progress message to all clients
|
|
async function sendProgress(message, progress, total) {
|
|
const clients = await self.clients.matchAll();
|
|
clients.forEach(client => {
|
|
client.postMessage({
|
|
type: 'CACHE_PROGRESS',
|
|
message,
|
|
progress,
|
|
total
|
|
});
|
|
});
|
|
}
|
|
|
|
// Pre-cache all trips when main page loads
|
|
let preCacheInProgress = false;
|
|
async function preCacheAllTrips() {
|
|
// Prevent duplicate runs
|
|
if (preCacheInProgress) return;
|
|
preCacheInProgress = true;
|
|
|
|
console.log('[SW] Pre-caching all trips (full offline mode)...');
|
|
|
|
// Small delay to ensure page JS has loaded
|
|
await new Promise(r => setTimeout(r, 500));
|
|
|
|
try {
|
|
const response = await fetch('/api/trips', { credentials: 'include' });
|
|
if (!response.ok) {
|
|
console.log('[SW] Failed to fetch trips list:', response.status);
|
|
await sendProgress('Offline sync failed - not logged in?', 0, 0);
|
|
preCacheInProgress = false;
|
|
return;
|
|
}
|
|
|
|
const trips = await response.json();
|
|
const dataCache = await caches.open(DATA_CACHE);
|
|
const imageCache = await caches.open(IMAGE_CACHE);
|
|
|
|
const totalTrips = trips.length;
|
|
let currentTrip = 0;
|
|
let totalAssets = 0;
|
|
let cachedAssets = 0;
|
|
|
|
await sendProgress('Starting offline sync...', 0, totalTrips);
|
|
|
|
// Cache the trips list API response
|
|
const tripsResponse = await fetch('/api/trips', { credentials: 'include' });
|
|
if (tripsResponse.ok) {
|
|
dataCache.put('/api/trips', tripsResponse);
|
|
}
|
|
|
|
// Cache each trip page and its assets
|
|
for (const trip of trips) {
|
|
currentTrip++;
|
|
const tripUrl = `/trip/${trip.id}`;
|
|
|
|
await sendProgress(`Caching: ${trip.name}`, currentTrip, totalTrips);
|
|
|
|
try {
|
|
const tripResponse = await fetch(tripUrl, { credentials: 'include' });
|
|
if (tripResponse.ok && tripResponse.status === 200) {
|
|
// Skip if redirected to login/auth
|
|
const finalUrl = tripResponse.url || '';
|
|
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) {
|
|
console.log('[SW] Auth required for trip:', trip.name);
|
|
continue;
|
|
}
|
|
|
|
// Clone response to read HTML and still cache it
|
|
const htmlText = await tripResponse.clone().text();
|
|
await dataCache.put(tripUrl, tripResponse);
|
|
console.log('[SW] Pre-cached trip:', trip.name);
|
|
|
|
// Extract and cache all images
|
|
const imageUrls = extractUrls(htmlText, /\/images\/[^"'\s<>]+/g);
|
|
totalAssets += imageUrls.length;
|
|
for (const imgUrl of imageUrls) {
|
|
try {
|
|
const existing = await caches.match(imgUrl);
|
|
if (!existing) {
|
|
const imgResponse = await fetch(imgUrl);
|
|
if (imgResponse.ok) {
|
|
await imageCache.put(imgUrl, imgResponse);
|
|
cachedAssets++;
|
|
console.log('[SW] Cached image:', imgUrl);
|
|
}
|
|
} else {
|
|
cachedAssets++;
|
|
}
|
|
} catch (e) { /* skip failed images */ }
|
|
}
|
|
|
|
// Extract and cache all documents
|
|
const docUrls = extractUrls(htmlText, /\/documents\/[^"'\s<>]+/g);
|
|
totalAssets += docUrls.length;
|
|
for (const docUrl of docUrls) {
|
|
try {
|
|
const existing = await caches.match(docUrl);
|
|
if (!existing) {
|
|
const docResponse = await fetch(docUrl);
|
|
if (docResponse.ok) {
|
|
await dataCache.put(docUrl, docResponse);
|
|
cachedAssets++;
|
|
console.log('[SW] Cached document:', docUrl);
|
|
}
|
|
} else {
|
|
cachedAssets++;
|
|
}
|
|
} catch (e) { /* skip failed docs */ }
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log('[SW] Failed to pre-cache trip:', trip.name, e);
|
|
}
|
|
}
|
|
|
|
await sendProgress('complete', totalTrips, totalTrips);
|
|
console.log('[SW] Full pre-caching complete!', totalTrips, 'trips,', cachedAssets, 'files');
|
|
preCacheInProgress = false;
|
|
} catch (error) {
|
|
console.error('[SW] Pre-caching failed:', error);
|
|
await sendProgress('Offline sync failed', 0, 0);
|
|
preCacheInProgress = false;
|
|
}
|
|
}
|
|
|
|
// Helper to extract URLs from HTML using regex
|
|
function extractUrls(html, pattern) {
|
|
const matches = html.match(pattern) || [];
|
|
// Dedupe and clean
|
|
return [...new Set(matches.map(url => {
|
|
// Decode HTML entities and remove trailing quotes
|
|
return url.replace(/&/g, '&').replace(/["'<>]/g, '');
|
|
}))];
|
|
}
|
|
|
|
// Handle static assets - cache first
|
|
async function handleStaticRequest(request) {
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
// Return cached, but also update cache in background
|
|
fetchAndCache(request, STATIC_CACHE);
|
|
return cachedResponse;
|
|
}
|
|
|
|
return fetchAndCache(request, STATIC_CACHE);
|
|
}
|
|
|
|
// Handle API requests - cache first for trip data, network for others
|
|
async function handleApiRequest(request) {
|
|
const url = new URL(request.url);
|
|
|
|
// These endpoints can be cached for offline use
|
|
const cacheableEndpoints = [
|
|
'/api/trips',
|
|
'/api/trip/'
|
|
];
|
|
|
|
const shouldCache = cacheableEndpoints.some(endpoint => url.pathname.startsWith(endpoint));
|
|
|
|
// NETWORK FIRST - always try network, only use cache when offline
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok && shouldCache) {
|
|
const cache = await caches.open(DATA_CACHE);
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
// Network failed - we're offline, try cache
|
|
console.log('[SW] API request failed (offline), trying cache:', request.url);
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
const headers = new Headers(cachedResponse.headers);
|
|
headers.set('X-From-Cache', 'true');
|
|
return new Response(cachedResponse.body, {
|
|
status: cachedResponse.status,
|
|
statusText: cachedResponse.statusText,
|
|
headers: headers
|
|
});
|
|
}
|
|
return createOfflineJsonResponse({ error: 'Offline - data not cached' });
|
|
}
|
|
}
|
|
|
|
// Fetch and cache API in background
|
|
async function fetchAndCacheApi(request) {
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(DATA_CACHE);
|
|
cache.put(request, response.clone());
|
|
}
|
|
} catch (e) {
|
|
// Silent fail
|
|
}
|
|
}
|
|
|
|
// Handle image requests - cache first (images don't change)
|
|
async function handleImageRequest(request) {
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(IMAGE_CACHE);
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.log('[SW] Image not available offline:', request.url);
|
|
return createPlaceholderImage();
|
|
}
|
|
}
|
|
|
|
// Handle document requests - cache first
|
|
async function handleDocumentRequest(request) {
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(DATA_CACHE);
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.log('[SW] Document not available offline:', request.url);
|
|
return createOfflineResponse('Document not available offline');
|
|
}
|
|
}
|
|
|
|
// Helper: fetch and cache
|
|
async function fetchAndCache(request, cacheName) {
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(cacheName);
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper: create offline HTML response
|
|
function createOfflineResponse(message) {
|
|
return new Response(
|
|
`<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Offline - Trips</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #1a1a2e;
|
|
color: #eee;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
text-align: center;
|
|
}
|
|
.offline-container {
|
|
padding: 40px;
|
|
}
|
|
.offline-icon {
|
|
font-size: 64px;
|
|
margin-bottom: 20px;
|
|
}
|
|
h1 { color: #fff; margin-bottom: 10px; }
|
|
p { color: #aaa; }
|
|
button {
|
|
background: #4a9eff;
|
|
color: white;
|
|
border: none;
|
|
padding: 12px 24px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
margin-top: 20px;
|
|
font-size: 16px;
|
|
}
|
|
button:hover { background: #3a8eef; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="offline-container">
|
|
<div class="offline-icon">📡</div>
|
|
<h1>You're Offline</h1>
|
|
<p>${message}</p>
|
|
<button onclick="location.reload()">Try Again</button>
|
|
</div>
|
|
</body>
|
|
</html>`,
|
|
{
|
|
status: 503,
|
|
headers: { 'Content-Type': 'text/html' }
|
|
}
|
|
);
|
|
}
|
|
|
|
// Helper: create offline JSON response
|
|
function createOfflineJsonResponse(data) {
|
|
return new Response(JSON.stringify(data), {
|
|
status: 503,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-From-Cache': 'false'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper: create placeholder image
|
|
function createPlaceholderImage() {
|
|
// 1x1 transparent PNG
|
|
const placeholder = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
const binary = atob(placeholder);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return new Response(bytes, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'image/png' }
|
|
});
|
|
}
|
|
|
|
// Listen for messages from the main thread
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting();
|
|
}
|
|
|
|
// Cache specific trip data on demand
|
|
if (event.data && event.data.type === 'CACHE_TRIP') {
|
|
const tripId = event.data.tripId;
|
|
cacheTripData(tripId);
|
|
}
|
|
|
|
// Clear all caches
|
|
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
|
caches.keys().then((names) => {
|
|
names.forEach((name) => {
|
|
if (name.startsWith('trips-')) {
|
|
caches.delete(name);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Force pre-cache all trips
|
|
if (event.data && event.data.type === 'PRECACHE_ALL') {
|
|
preCacheAllTrips();
|
|
}
|
|
});
|
|
|
|
// Cache trip data for offline use
|
|
async function cacheTripData(tripId) {
|
|
const cache = await caches.open(DATA_CACHE);
|
|
|
|
try {
|
|
// Cache trip page
|
|
const tripPageUrl = `/trip/${tripId}`;
|
|
const tripPageResponse = await fetch(tripPageUrl);
|
|
if (tripPageResponse.ok) {
|
|
await cache.put(tripPageUrl, tripPageResponse);
|
|
}
|
|
|
|
console.log('[SW] Cached trip data for:', tripId);
|
|
} catch (error) {
|
|
console.error('[SW] Failed to cache trip:', error);
|
|
}
|
|
}
|