diff --git a/404.php b/404.php
index 625ee92..b9928b5 100644
--- a/404.php
+++ b/404.php
@@ -72,6 +72,7 @@ http_response_code(404);
+
+
+ You’re offline
+ MVLog could not reach the network. Previously opened pages and assets may still be available.
+ Try home again
+
+
+
+
diff --git a/sw.js b/sw.js
index 7424e88..a2234f1 100644
--- a/sw.js
+++ b/sw.js
@@ -1,3 +1,119 @@
+const MVLOG_CACHE_PREFIX = 'mvlog-pwa-';
+const MVLOG_CACHE_VERSION = 'mvlog-pwa-v1';
+const APP_SHELL_CACHE = `${MVLOG_CACHE_VERSION}-app-shell`;
+const RUNTIME_CACHE = `${MVLOG_CACHE_VERSION}-runtime`;
+
+const APP_SHELL_URLS = [
+ '/',
+ '/offline.html',
+ '/style.css',
+ '/js/map_lightbox.js',
+ '/assets/fonts/BebasNeue-Regular.ttf',
+ '/assets/fonts/IBMPlexSans-SemiBold.ttf',
+ '/assets/fonts/NotoSans-Bold.ttf',
+ '/assets/img/background.jpg',
+ '/assets/img/moto_travel.png',
+ '/assets/img/pwa-icon-192.png',
+ '/assets/img/pwa-icon-512.png',
+ '/assets/img/pwa-maskable-512.png',
+ '/assets/img/apple-touch-icon.png',
+ '/manifest.json'
+];
+
+self.addEventListener('install', event => {
+ event.waitUntil((async () => {
+ const cache = await caches.open(APP_SHELL_CACHE);
+ await Promise.all(APP_SHELL_URLS.map(url =>
+ cache.add(new Request(url, { cache: 'reload' })).catch(() => undefined)
+ ));
+ await self.skipWaiting();
+ })());
+});
+
+self.addEventListener('activate', event => {
+ event.waitUntil((async () => {
+ const names = await caches.keys();
+ await Promise.all(names.map(name => {
+ if (name.startsWith(MVLOG_CACHE_PREFIX) && ![APP_SHELL_CACHE, RUNTIME_CACHE].includes(name)) {
+ return caches.delete(name);
+ }
+ return undefined;
+ }));
+ await clients.claim();
+ })());
+});
+
+function shouldBypassFetch(request, url) {
+ if (request.method !== 'GET') return true;
+ if (!['http:', 'https:'].includes(url.protocol)) return true;
+ if (url.origin !== self.location.origin) return false;
+
+ const path = url.pathname;
+ if (path === '/admin.php' || path === '/login.php' || path === '/logout.php') return true;
+ if (path === '/push_config.php' || path === '/push_subscribe.php' || path === '/job_status.php') return true;
+ if (path.startsWith('/api/') || path.startsWith('/lib/') || path.startsWith('/cache/') || path.startsWith('/in-dir/')) return true;
+ if (path.startsWith('/out-dir/') && /\.(mp4|m4v|mov|webm)$/i.test(path)) return true;
+ if (/\.(mp4|m4v|mov|webm)$/i.test(path)) return true;
+
+ return false;
+}
+
+function isStaticAsset(url) {
+ if (url.origin !== self.location.origin) return false;
+ const path = url.pathname;
+ return path === '/style.css'
+ || path === '/manifest.json'
+ || path === '/offline.html'
+ || path.startsWith('/assets/')
+ || path.startsWith('/js/');
+}
+
+async function networkFirstNavigation(request) {
+ try {
+ const response = await fetch(request);
+ if (response && response.ok) {
+ const cache = await caches.open(RUNTIME_CACHE);
+ cache.put(request, response.clone()).catch(() => undefined);
+ }
+ return response;
+ } catch (error) {
+ const exact = await caches.match(request);
+ if (exact) return exact;
+ const offline = await caches.match('/offline.html');
+ if (offline) return offline;
+ const home = await caches.match('/');
+ if (home) return home;
+ return new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
+ }
+}
+
+async function staleWhileRevalidate(request) {
+ const cached = await caches.match(request);
+ const cache = await caches.open(RUNTIME_CACHE);
+ const network = fetch(request).then(response => {
+ if (response && response.ok) {
+ cache.put(request, response.clone()).catch(() => undefined);
+ }
+ return response;
+ }).catch(() => cached);
+ return cached || network;
+}
+
+self.addEventListener('fetch', event => {
+ const request = event.request;
+ const url = new URL(request.url);
+ if (shouldBypassFetch(request, url)) return;
+
+ if (request.mode === 'navigate') {
+ event.respondWith(networkFirstNavigation(request));
+ return;
+ }
+
+ if (isStaticAsset(url)) {
+ event.respondWith(staleWhileRevalidate(request));
+ }
+});
+
self.addEventListener('push', event => {
let data = {};
try { data = event.data ? event.data.json() : {}; } catch (e) { data = { title: 'MVLog', body: event.data ? event.data.text() : 'New article @ MVLog!' }; }