diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index b937e52..9b0c7a8 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -377,6 +377,7 @@ PWA_APP_SCREENSHOTS = [ "type": "image/png", }, ] +PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js" ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true" KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365")) diff --git a/app/templates/offline.html b/app/templates/offline.html new file mode 100644 index 0000000..4e738ac --- /dev/null +++ b/app/templates/offline.html @@ -0,0 +1,79 @@ + + + + + + Offline + + + +
+ + + + +

Either you or your WYGIWYH instance is offline.

+
+
+ + + + diff --git a/app/templates/pwa/serviceworker.js b/app/templates/pwa/serviceworker.js new file mode 100644 index 0000000..3dfdfba --- /dev/null +++ b/app/templates/pwa/serviceworker.js @@ -0,0 +1,74 @@ +// Base Service Worker implementation. To use your own Service Worker, set the PWA_SERVICE_WORKER_PATH variable in settings.py + +var staticCacheName = "django-pwa-v" + new Date().getTime(); +var filesToCache = [ + '/offline/', + '/static/css/django-pwa-app.css', + '/static/img/favicon/android-icon-192x192.png', + '/static/img/favicon/apple-icon-180x180.png', + '/static/img/pwa/splash-640x1136.png', + '/static/img/pwa/splash-750x1334.png', +]; + +// Cache on install +self.addEventListener("install", event => { + this.skipWaiting(); + event.waitUntil( + caches.open(staticCacheName) + .then(cache => { + return cache.addAll(filesToCache); + }) + ); +}); + +// Clear cache on activate +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames + .filter(cacheName => (cacheName.startsWith("django-pwa-"))) + .filter(cacheName => (cacheName !== staticCacheName)) + .map(cacheName => caches.delete(cacheName)) + ); + }) + ); +}); + +// Serve from Cache +self.addEventListener("fetch", event => { + event.respondWith( + caches.match(event.request) + .then(response => { + if (response) { + return response; + } + return fetch(event.request).catch(() => { + const isHtmxRequest = event.request.headers.get('HX-Request') === 'true'; + const isHtmxBoosted = event.request.headers.get('HX-Boosted') === 'true'; + + if (!isHtmxRequest || isHtmxBoosted) { + // Serve offline content without changing URL + return caches.match('/offline/').then(offlineResponse => { + if (offlineResponse) { + return offlineResponse.text().then(offlineText => { + return new Response(offlineText, { + status: 200, + headers: {'Content-Type': 'text/html'} + }); + }); + } + // If offline page is not in cache, return a simple offline message + return new Response('

Offline

The page is not available offline.

', { + status: 200, + headers: {'Content-Type': 'text/html'} + }); + }); + } else { + // For non-boosted HTMX requests, let it fail normally + throw new Error('Network request failed'); + } + }); + }) + ); +}); diff --git a/frontend/src/styles/_animations.scss b/frontend/src/styles/_animations.scss index 765a921..5b9c135 100644 --- a/frontend/src/styles/_animations.scss +++ b/frontend/src/styles/_animations.scss @@ -58,13 +58,21 @@ // HTMX Loading @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } @keyframes fade-in { - 0% { opacity: 0; } - 100% { opacity: 1; } + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } } .show-loading.htmx-request { @@ -103,7 +111,7 @@ } .swing-out-top-bck { - animation: swing-out-top-bck 0.45s cubic-bezier(0.600, -0.280, 0.735, 0.045) both; + animation: swing-out-top-bck 0.45s cubic-bezier(0.600, -0.280, 0.735, 0.045) both; } /* ---------------------------------------------- @@ -155,7 +163,7 @@ } .scale-in-center { - animation: scale-in-center 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both; + animation: scale-in-center 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both; } /* ---------------------------------------------- @@ -182,5 +190,18 @@ } .scale-out-center { - animation: scale-out-center 0.5s cubic-bezier(0.550, 0.085, 0.680, 0.530) both; + animation: scale-out-center 0.5s cubic-bezier(0.550, 0.085, 0.680, 0.530) both; +} + +@keyframes flash { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +.flashing { + animation: flash 1s infinite; } diff --git a/frontend/src/styles/style.scss b/frontend/src/styles/style.scss index 1bb7f8a..7fc980b 100644 --- a/frontend/src/styles/style.scss +++ b/frontend/src/styles/style.scss @@ -53,3 +53,27 @@ select[multiple] { .transaction:has(input[type="checkbox"]:checked) > .transaction-item { background-color: $primary-bg-subtle-dark; } + +.offline { + text-align: center; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #222; + color: #fbb700; + font-family: Arial, sans-serif; +} + +.wifi-icon { + width: 100px; + height: 100px; +} + +#offline-countdown { + margin-top: 20px; + font-size: 14px; +}