From 8f62eea851b5b8ef19e5246ad0a4611ed3db39a3 Mon Sep 17 00:00:00 2001 From: abk-tech Date: Sun, 26 Apr 2026 13:47:34 +0100 Subject: [PATCH] feat/implement PWA --- public/offline.html | 73 ++++++++++++++++++++++++ src/next.config.ts | 51 ++++++++++++++++- src/serviceWorker.ts | 128 ++++++++++++++++++++++++++++--------------- 3 files changed, 208 insertions(+), 44 deletions(-) create mode 100644 public/offline.html diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 00000000..b513fa5c --- /dev/null +++ b/public/offline.html @@ -0,0 +1,73 @@ + + + + + + Offline - TeachLink + + + +
+
+ + + +
+

You're Offline

+

It looks like you've lost your internet connection. Some features may not be available until you're back online.

+ Try Again +
+ + \ No newline at end of file diff --git a/src/next.config.ts b/src/next.config.ts index 5e891cf0..87356c04 100644 --- a/src/next.config.ts +++ b/src/next.config.ts @@ -1,7 +1,56 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - /* config options here */ + // PWA Service Worker configuration + headers: async () => [ + { + source: '/service-worker.js', + headers: [ + { key: 'Cache-Control', value: 'public, max-age=0, must-revalidate' }, + { key: 'Service-Worker-Allowed', value: '/' }, + ], + }, + ], + // Workbox configuration for PWA + workbox: { + // Caching strategies + runtimeCaching: [ + // API requests - NetworkFirst + { + urlPattern: /^https?.*\/api\/.*$/, + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }, + networkTimeoutSeconds: 10, + }, + }, + // Static assets - StaleWhileRevalidate + { + urlPattern: /\.(?:js|css)$/, + handler: 'StaleWhileRevalidate', + options: { cacheName: 'static-resources' }, + }, + // Images - CacheFirst + { + urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/, + handler: 'CacheFirst', + options: { + cacheName: 'images-cache', + expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, + }, + }, + // Fonts - CacheFirst + { + urlPattern: /\.(?:woff2?|ttf|otf|eot)$/, + handler: 'CacheFirst', + options: { + cacheName: 'fonts-cache', + expiration: { maxEntries: 20, maxAgeSeconds: 365 * 24 * 60 * 60 }, + }, + }, + ], + }, }; export default nextConfig; diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index cb0f7a67..0e444cc5 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -11,54 +11,75 @@ declare const self: ServiceWorkerGlobalScope; clientsClaim(); // Precache all of the assets generated by your build process. -// Their URLs are injected into the manifest variable below. -// This variable must be present somewhere in your service worker file, -// even if you decide not to use precaching. See https://cra.link/PWA precacheAndRoute(self.__WB_MANIFEST || []); -// Set up App Shell-style routing, so that navigation requests are fulfilled with your index.html shell. -// This is for Single Page Apps. +// Offline fallback page +const offlineFallbackPage = '/offline.html'; + +// Set up App Shell-style routing const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$'); registerRoute( - // Return false to exempt requests from being fulfilled by index.html. ({ request, url }: { request: Request; url: URL }) => { - // If this isn't a navigation, skip. - if (request.mode !== 'navigate') { - return false; - } + if (request.mode !== 'navigate') return false; + if (url.pathname.startsWith('/_')) return false; + if (url.pathname.match(fileExtensionRegexp)) return false; + return true; + }, + createHandlerBoundToURL('/index.html'), +); - // If this is a URL that starts with /_, skip. - if (url.pathname.startsWith('/_')) { - return false; +// Navigation fallback for offline +registerRoute( + ({ request, url }: { request: Request; url: URL }) => { + if (request.mode !== 'navigate') return false; + return true; + }, + async () => { + try { + const response = await fetch(offlineFallbackPage); + if (response) { + const cache = await caches.open('offline-fallback'); + await cache.put(offlineFallbackPage, response.clone()); + return response; + } + } catch (error) { + const cache = await caches.open('offline-fallback'); + const cachedResponse = await cache.match(offlineFallbackPage); + if (cachedResponse) return cachedResponse; } + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + }, +); - // If this looks like a URL for a resource, because it has a file extension, skip. - if (url.pathname.match(fileExtensionRegexp)) { - return false; - } +// Runtime caching for static assets +registerRoute( + ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.js'), + new StaleWhileRevalidate({ cacheName: 'static-js' }), +); - // Return true to signal that we want to use the handler. - return true; - }, - createHandlerBoundToURL('/index.html'), +registerRoute( + ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.css'), + new StaleWhileRevalidate({ cacheName: 'static-css' }), ); -// An example runtime caching route for requests that aren't handled by the precache, -// in this case same-origin .png requests like those from in public/ +// Runtime caching for images registerRoute( - // Add in any other file extensions or routing criteria as needed. ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), - // Customize this strategy as needed, e.g., by changing to CacheFirst. - new StaleWhileRevalidate({ + new CacheFirst({ cacheName: 'images', - plugins: [ - // Ensure that once this runtime cache reaches a maximum size the least-recently used images are removed. - new ExpirationPlugin({ maxEntries: 50 }), - ], + plugins: [new ExpirationPlugin({ maxEntries: 50 })], }), ); -// Cache common external image providers +registerRoute( + ({ url }) => url.origin === self.location.origin && url.pathname.match(/\.(jpg|jpeg|svg|gif|webp)$/), + new CacheFirst({ + cacheName: 'images-ext', + plugins: [new ExpirationPlugin({ maxEntries: 100 })], + }), +); + +// External images registerRoute( ({ url }) => url.hostname === 'images.unsplash.com' || @@ -66,39 +87,60 @@ registerRoute( url.hostname === 'static.vecteezy.com', new StaleWhileRevalidate({ cacheName: 'external-images', - plugins: [ - new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 Days - ], + plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 })], }), ); -// Cache API requests +// API requests with NetworkFirst registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api-responses', - plugins: [ - new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }), // 24 Hours - ], + plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 })], + }), +); + +// Font caching +registerRoute( + ({ url }) => url.pathname.match(/\.(woff2?|ttf|otf|eot)$/), + new CacheFirst({ + cacheName: 'fonts', + plugins: [new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * 24 * 60 * 60 })], }), ); // Background Sync for offline actions const bgSyncPlugin = new BackgroundSyncPlugin('teachLinkSyncQueue', { - maxRetentionTime: 24 * 60, // Retry for max 24 Hours (in minutes) + maxRetentionTime: 24 * 60, }); registerRoute( ({ url }) => url.pathname.startsWith('/api/sync/'), - new NetworkFirst({ - plugins: [bgSyncPlugin], - }), + new NetworkFirst({ plugins: [bgSyncPlugin] }), 'POST', ); -// This allows the web app to trigger skipWaiting via registration.waiting.postMessage({type: 'SKIP_WAITING'}) +// Handle sync events for background sync +self.addEventListener('sync', (event: SyncEvent) => { + if (event.tag === 'teachLinkSyncQueue') { + event.waitUntil( + // Sync logic handled by the queue + Promise.resolve(), + ); + } +}); + +// Skip waiting and claim clients self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } }); + +self.addEventListener('activate', (event) => { + event.waitUntil(clientsClaim()); +}); + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +});