diff --git a/config/urls.py b/config/urls.py index d61560b..574afd1 100644 --- a/config/urls.py +++ b/config/urls.py @@ -6,18 +6,24 @@ from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from django.views.generic import TemplateView from users.urls import api_urlpatterns as users_api_urls, web_urlpatterns as users_web_urls from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls from sync.urls import api_urlpatterns as sync_api_urls from notifications.urls import api_urlpatterns as notifications_api_urls from tasks.views_debug import debug_user_agent -from .views import api_root +from .views import api_root, manifest_webmanifest, service_worker urlpatterns = [ # Admin path('admin/', admin.site.urls), + # PWA + path('manifest.webmanifest', manifest_webmanifest, name='manifest'), + path('sw.js', service_worker, name='service-worker'), + path('offline/', TemplateView.as_view(template_name='offline.html'), name='offline'), + # Debug endpoint (remove in production) path('debug/user-agent/', debug_user_agent, name='debug-user-agent'), diff --git a/config/views.py b/config/views.py index 47c2a8b..5be4937 100644 --- a/config/views.py +++ b/config/views.py @@ -3,6 +3,23 @@ API root views for KeepItGoing. """ from django.http import JsonResponse +from django.shortcuts import render + + +def manifest_webmanifest(request): + """Serve the PWA web app manifest, rendered so icon URLs pick up static hashing.""" + return render(request, 'manifest.webmanifest', content_type='application/manifest+json') + + +def service_worker(request): + """ + Serve the service worker script from the site root so its default scope + covers the whole app (a service worker can only control paths at or + below where it's served from). + """ + response = render(request, 'sw.js', content_type='application/javascript') + response['Cache-Control'] = 'no-cache' + return response def api_root(request): diff --git a/static/favicons/apple-touch-icon-180.png b/static/favicons/apple-touch-icon-180.png new file mode 100644 index 0000000..18482ba Binary files /dev/null and b/static/favicons/apple-touch-icon-180.png differ diff --git a/static/favicons/icon-192.png b/static/favicons/icon-192.png new file mode 100644 index 0000000..582da33 Binary files /dev/null and b/static/favicons/icon-192.png differ diff --git a/static/favicons/icon-512-maskable.png b/static/favicons/icon-512-maskable.png new file mode 100644 index 0000000..ac8aef9 Binary files /dev/null and b/static/favicons/icon-512-maskable.png differ diff --git a/static/favicons/icon-512.png b/static/favicons/icon-512.png new file mode 100644 index 0000000..689f9b7 Binary files /dev/null and b/static/favicons/icon-512.png differ diff --git a/static/js/app.js b/static/js/app.js index b05984a..7d13d2d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -7,8 +7,19 @@ document.addEventListener('DOMContentLoaded', function() { initTheme(); initTaskSelection(); initTimerDisplays(); + registerServiceWorker(); }); +/* ============================================ + Service Worker + ============================================ */ + +function registerServiceWorker() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js'); + } +} + /* ============================================ Theme Toggle ============================================ */ diff --git a/templates/base.html b/templates/base.html index e999d8b..7a7c838 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,6 +9,13 @@ + + + + + + + {% block extra_css %}{% endblock %} diff --git a/templates/manifest.webmanifest b/templates/manifest.webmanifest new file mode 100644 index 0000000..ad5da74 --- /dev/null +++ b/templates/manifest.webmanifest @@ -0,0 +1,26 @@ +{% load static %}{ + "name": "KeepItGoing", + "short_name": "KeepItGoing", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#3B82F6", + "icons": [ + { + "src": "{% static 'favicons/icon-192.png' %}", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "{% static 'favicons/icon-512.png' %}", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "{% static 'favicons/icon-512-maskable.png' %}", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/templates/offline.html b/templates/offline.html new file mode 100644 index 0000000..b248d11 --- /dev/null +++ b/templates/offline.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block title %}You're Offline - KeepItGoing{% endblock %} + +{% block content %} +
+

You're offline

+

This page isn't available without a connection. Reconnect and try again.

+ +
+{% endblock %} + +{% block auth_content %} +
+

You're offline

+

This page isn't available without a connection. Reconnect and try again.

+ +
+{% endblock %} diff --git a/templates/sw.js b/templates/sw.js new file mode 100644 index 0000000..9e2bf9a --- /dev/null +++ b/templates/sw.js @@ -0,0 +1,49 @@ +{% load static %}const CACHE_NAME = 'keepitgoing-shell-v1'; +const OFFLINE_URL = '{% url "offline" %}'; + +const PRECACHE_URLS = [ + "{% static 'css/app.css' %}", + "{% static 'js/app.js' %}", + "{% static 'favicons/favicon.svg' %}", + "{% static 'favicons/icon-192.png' %}", + "{% static 'favicons/icon-512.png' %}", + "{% static 'favicons/icon-512-maskable.png' %}", + "{% static 'favicons/apple-touch-icon-180.png' %}", + OFFLINE_URL, +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + + // Navigations: try the network first, fall back to the offline page. + if (request.mode === 'navigate') { + event.respondWith( + fetch(request).catch(() => caches.match(OFFLINE_URL)) + ); + return; + } + + // Precached shell assets: serve from cache first. + const path = new URL(request.url).pathname; + if (PRECACHE_URLS.includes(path)) { + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request)) + ); + } +});