Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions public/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - TeachLink</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #f8fafc;
}
.container {
text-align: center;
padding: 2rem;
max-width: 400px;
}
.icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: #334155;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 40px;
height: 40px;
stroke: #94a3b8;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
color: #f1f5f9;
}
p {
color: #94a3b8;
margin-bottom: 1.5rem;
line-height: 1.6;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #2563eb;
color: white;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 500;
transition: background 0.2s;
}
.btn:hover { background: #1d4ed8; }
</style>
</head>
<body>
<div class="container">
<div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Some features may not be available until you're back online.</p>
<a href="/" class="btn">Try Again</a>
</div>
</body>
</html>
51 changes: 50 additions & 1 deletion src/next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
128 changes: 85 additions & 43 deletions src/serviceWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,94 +11,136 @@ 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' ||
url.hostname === 'thumbs.dreamstime.com' ||
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();
}
});
Loading