From 5d81e00aeb0bf83e380d234af68226a7ee27344e Mon Sep 17 00:00:00 2001 From: ekke Date: Tue, 27 Jan 2026 09:53:37 +0000 Subject: [PATCH] files for /public within /public there is a folder called /public/icons which holds all the icons mentioned in manfest.json --- manifest.json | 64 ++++++++++ offline.html | 92 +++++++++++++++ sw.js | 321 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 manifest.json create mode 100644 offline.html create mode 100644 sw.js diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..1ceeee5 --- /dev/null +++ b/manifest.json @@ -0,0 +1,64 @@ +{ + "name": "LUPMIS2 Drawing Tools", + "short_name": "LUPMIS", + "description": "Map and GIS functions for Land Use Planning in Ghana", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f5f5f5", + "theme_color": "#6CCB2D", + "orientation": "any", + "icons": [ + { + "src": "/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["productivity", "utilities"], + "lang": "en", + "dir": "ltr" +} diff --git a/offline.html b/offline.html new file mode 100644 index 0000000..8d87b7a --- /dev/null +++ b/offline.html @@ -0,0 +1,92 @@ + + + + + + + Offline - LUPMIS + + + +
+
📴
+

You're Offline

+

+ This page isn't available offline. Please check your internet connection and try again. +

+ + +
+ 💡 Tip: Visit the main app while online first. + Once cached, you can collect data offline! +
+
+ + + + diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..257c9a9 --- /dev/null +++ b/sw.js @@ -0,0 +1,321 @@ +/** + * Service Worker + * + * Handles caching of: + * - App shell (HTML, CSS, JS) + * - Map tiles (runtime caching) + * - API responses (network-first) + * + * Note: Database operations are handled by the SharedWorker (shared-db-worker.js), + * NOT by this service worker. They serve different purposes: + * - Service Worker: Caching, offline asset serving, push notifications + * - SharedWorker: Shared database connection across tabs + */ + +const CACHE_VERSION = 'v1'; +const SHELL_CACHE = `shell-${CACHE_VERSION}`; +const TILES_CACHE = `tiles-${CACHE_VERSION}`; +const MODULES_CACHE = `modules-${CACHE_VERSION}`; +const API_CACHE = `api-${CACHE_VERSION}`; + +// Maximum number of tiles to cache +const MAX_TILES = 500; + +// App shell assets - precached on install +// Vite will generate hashed filenames, so we cache the entry points +// and let the browser handle the hashed assets +const SHELL_ASSETS = [ + '/', + '/index.html', + '/offline.html', + '/manifest.json' +]; + +// ============================================================================ +// INSTALL EVENT +// ============================================================================ + +self.addEventListener('install', (event) => { + console.log('[SW] Installing...'); + + event.waitUntil( + caches.open(SHELL_CACHE) + .then((cache) => { + console.log('[SW] Precaching app shell'); + return cache.addAll(SHELL_ASSETS); + }) + .then(() => self.skipWaiting()) + ); +}); + +// ============================================================================ +// ACTIVATE EVENT +// ============================================================================ + +self.addEventListener('activate', (event) => { + console.log('[SW] Activating...'); + + event.waitUntil( + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => { + // Delete old version caches + return (name.startsWith('shell-') && name !== SHELL_CACHE) || + (name.startsWith('tiles-') && name !== TILES_CACHE) || + (name.startsWith('modules-') && name !== MODULES_CACHE) || + (name.startsWith('api-') && name !== API_CACHE); + }) + .map((name) => { + console.log('[SW] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }) + .then(() => self.clients.claim()) + ); +}); + +// ============================================================================ +// FETCH EVENT +// ============================================================================ + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + + // Only handle GET requests + if (request.method !== 'GET') return; + + // Skip chrome-extension and other non-http(s) requests + if (!url.protocol.startsWith('http')) return; + + // Route to appropriate caching strategy + if (isMapTile(url)) { + event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES)); + } else if (isApiRequest(url)) { + event.respondWith(networkFirst(request, API_CACHE)); + } else if (isModuleAsset(url)) { + event.respondWith(staleWhileRevalidate(request, MODULES_CACHE)); + } else if (isAppAsset(url)) { + event.respondWith(cacheFirst(request, SHELL_CACHE)); + } + // Let other requests pass through to network +}); + +// ============================================================================ +// URL CLASSIFICATION +// ============================================================================ + +function isMapTile(url) { + // Common tile server patterns for all our base maps + return url.hostname.includes('tile.openstreetmap.org') || + url.hostname.includes('opentopomap.org') || + url.hostname.includes('arcgisonline.com') || + url.hostname.includes('basemaps.cartocdn.com') || + url.hostname.includes('tiles.') || + url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) || + url.pathname.match(/\/tile\/\d+\/\d+\/\d+/); +} + +function isApiRequest(url) { + return url.pathname.startsWith('/api/') || + url.pathname.endsWith('.php'); +} + +function isModuleAsset(url) { + return url.pathname.startsWith('/modules/'); +} + +function isAppAsset(url) { + // Same origin, common asset extensions + return url.origin === self.location.origin && + (url.pathname.endsWith('.html') || + url.pathname.endsWith('.css') || + url.pathname.endsWith('.js') || + url.pathname.endsWith('.wasm') || + url.pathname.endsWith('.json') || + url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp)$/)); +} + +// ============================================================================ +// CACHING STRATEGIES +// ============================================================================ + +/** + * Cache First - Use cache, fallback to network + * Best for: App shell, static assets + */ +async function cacheFirst(request, cacheName) { + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Return offline page for navigation requests + if (request.mode === 'navigate') { + return caches.match('/offline.html'); + } + throw error; + } +} + +/** + * Network First - Try network, fallback to cache + * Best for: API requests, dynamic content + */ +async function networkFirst(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 cached = await caches.match(request); + if (cached) return cached; + throw error; + } +} + +/** + * Stale While Revalidate - Return cache immediately, update in background + * Best for: Module assets, frequently updated content + */ +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + const fetchPromise = fetch(request).then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }).catch(() => cached); + + return cached || fetchPromise; +} + +/** + * Cache Then Network with limit - Cache tiles with size limit + * Best for: Map tiles + */ +async function cacheThenNetwork(request, cacheName, maxItems) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + if (cached) return cached; + + try { + const response = await fetch(request); + + if (response.ok) { + // Check cache size and trim if needed + const keys = await cache.keys(); + if (keys.length >= maxItems) { + // Remove oldest entries (first 10%) + const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1)); + await Promise.all(toDelete.map(key => cache.delete(key))); + } + + cache.put(request, response.clone()); + } + + return response; + } catch (error) { + // For tiles, just fail silently - map will show blank tile + return new Response('', { status: 408, statusText: 'Offline' }); + } +} + +// ============================================================================ +// MESSAGE HANDLING +// ============================================================================ + +self.addEventListener('message', (event) => { + const { type, payload } = event.data || {}; + + switch (type) { + case 'SKIP_WAITING': + self.skipWaiting(); + break; + + case 'CACHE_MODULES': + cacheModules(payload.modules); + break; + + case 'CLEAR_USER_CACHE': + clearUserCaches(); + break; + + case 'GET_CACHE_STATUS': + getCacheStatus().then(status => { + event.source.postMessage({ type: 'CACHE_STATUS', status }); + }); + break; + } +}); + +/** + * Cache specific modules on demand + */ +async function cacheModules(moduleNames) { + const cache = await caches.open(MODULES_CACHE); + + for (const moduleName of moduleNames) { + try { + const moduleAssets = [ + `/modules/${moduleName}/index.js`, + `/modules/${moduleName}/index.css`, + `/modules/${moduleName}/index.html` + ]; + + await cache.addAll(moduleAssets.filter(async (url) => { + // Only cache assets that exist + try { + const response = await fetch(url, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } + })); + + console.log('[SW] Cached module:', moduleName); + } catch (error) { + console.warn('[SW] Failed to cache module:', moduleName, error); + } + } +} + +/** + * Clear user-specific caches (call on logout) + */ +async function clearUserCaches() { + await caches.delete(API_CACHE); + await caches.delete(MODULES_CACHE); + console.log('[SW] Cleared user caches'); +} + +/** + * Get cache status information + */ +async function getCacheStatus() { + const cacheNames = await caches.keys(); + const status = {}; + + for (const name of cacheNames) { + const cache = await caches.open(name); + const keys = await cache.keys(); + status[name] = keys.length; + } + + return status; +}