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();
+ }
+});