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
39 changes: 39 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const CACHE_NAME = 'teachlink-v1';
const OFFLINE_URL = '/offline.html';

// Claim all clients immediately on activation
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL)),
);
});

self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
// Remove old caches
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))),
),
// Take control of all open clients immediately
self.clients.claim(),
]),
);
});

self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match(OFFLINE_URL).then((r) => r ?? new Response('Offline', { status: 503 })),
),
);
}
});

// Allow the waiting SW to skip the waiting phase and activate immediately
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
50 changes: 50 additions & 0 deletions src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

import React from 'react';
import { RefreshCw, X } from 'lucide-react';

interface UpdateBannerProps {
onUpdate: () => void;
onDismiss: () => void;
}

export const UpdateBanner: React.FC<UpdateBannerProps> = ({ onUpdate, onDismiss }) => (
<div
role="alert"
aria-live="polite"
className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-blue-600 text-white p-4 rounded-lg shadow-2xl z-[9999] animate-in slide-in-from-bottom-5 duration-300"
>
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-500 rounded-lg shrink-0">
<RefreshCw className="w-5 h-5" aria-hidden="true" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-sm">Update Available</h4>
<p className="text-xs text-blue-100 mt-1">
A new version of TeachLink is ready. Refresh to get the latest features.
</p>
<div className="flex gap-2 mt-3">
<button
onClick={onUpdate}
className="px-3 py-1.5 bg-white text-blue-600 text-xs font-bold rounded hover:bg-blue-50 transition-colors"
>
Update Now
</button>
<button
onClick={onDismiss}
className="px-3 py-1.5 bg-blue-500/50 text-white text-xs font-medium rounded hover:bg-blue-500 transition-colors"
>
Later
</button>
</div>
</div>
<button
onClick={onDismiss}
aria-label="Dismiss update notification"
className="p-1 hover:bg-blue-500 rounded-full transition-colors shrink-0"
>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
);
38 changes: 2 additions & 36 deletions src/components/pwa/AppUpdateManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,13 @@

import React from 'react';
import { usePWA } from '@/hooks/usePWA';
import { RefreshCw, X } from 'lucide-react';
import { UpdateBanner } from '@/components/UpdateBanner';

export const AppUpdateManager: React.FC = () => {
const { updateAvailable, updateApp } = usePWA();
const [show, setShow] = React.useState(true);

if (!updateAvailable || !show) return null;

return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-blue-600 text-white p-4 rounded-lg shadow-2xl z-[9999] animate-in slide-in-from-bottom-5 duration-300">
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-500 rounded-lg">
<RefreshCw className="w-5 h-5 animate-spin-slow" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-sm">Update Available</h4>
<p className="text-xs text-blue-100 mt-1">
A new version of TeachLink is available with new features and improvements.
</p>
<div className="flex gap-2 mt-3">
<button
onClick={updateApp}
className="px-3 py-1.5 bg-white text-blue-600 text-xs font-bold rounded hover:bg-blue-50 transition-colors"
>
Update Now
</button>
<button
onClick={() => setShow(false)}
className="px-3 py-1.5 bg-blue-500/50 text-white text-xs font-medium rounded hover:bg-blue-500 transition-colors"
>
Later
</button>
</div>
</div>
<button
onClick={() => setShow(false)}
className="p-1 hover:bg-blue-500 rounded-full transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
return <UpdateBanner onUpdate={updateApp} onDismiss={() => setShow(false)} />;
};
47 changes: 8 additions & 39 deletions src/hooks/usePWA.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { registerSW, applyUpdate } from '@/utils/registerSW';

interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
Expand All @@ -17,7 +18,6 @@ export const usePWA = () => {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
const [isOffline, setIsOffline] = useState(false);
const updateFoundHandlerRef = useRef<(() => void) | null>(null);

useEffect(() => {
// Check if app is already installed
Expand Down Expand Up @@ -57,37 +57,13 @@ export const usePWA = () => {
}, []);

const registerServiceWorker = useCallback(async () => {
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
try {
const reg = await navigator.serviceWorker.register('/serviceWorker.js');
setRegistration(reg);

const handleUpdateFound = () => {
const newWorker = reg.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setUpdateAvailable(true);
}
});
}
};
updateFoundHandlerRef.current = handleUpdateFound;
reg.addEventListener('updatefound', handleUpdateFound);
} catch (error) {
console.error('Service worker registration failed:', error);
}
}
const reg = await registerSW((r) => {
setRegistration(r);
setUpdateAvailable(true);
});
if (reg) setRegistration(reg);
}, []);

useEffect(() => {
return () => {
if (registration && updateFoundHandlerRef.current) {
registration.removeEventListener('updatefound', updateFoundHandlerRef.current);
}
};
}, [registration]);

const installApp = async () => {
if (!installPrompt) return;
await installPrompt.prompt();
Expand All @@ -98,14 +74,7 @@ export const usePWA = () => {
};

const updateApp = () => {
if (registration?.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
registration.waiting.addEventListener('statechange', (e) => {
if ((e.target as ServiceWorker).state === 'activated') {
window.location.reload();
}
});
}
if (registration) applyUpdate(registration);
};

return {
Expand Down
48 changes: 48 additions & 0 deletions src/utils/registerSW.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export type UpdateCallback = (registration: ServiceWorkerRegistration) => void;

/**
* Registers /sw.js and calls `onUpdate` whenever a new service worker is
* waiting to activate (i.e. an update is available).
*/
export async function registerSW(onUpdate?: UpdateCallback): Promise<ServiceWorkerRegistration | null> {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return null;

try {
const registration = await navigator.serviceWorker.register('/sw.js');

const checkForWaiting = (reg: ServiceWorkerRegistration) => {
if (reg.waiting) {
onUpdate?.(reg);
return;
}

reg.addEventListener('updatefound', () => {
const installing = reg.installing;
if (!installing) return;

installing.addEventListener('statechange', () => {
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
onUpdate?.(reg);
}
});
});
};

checkForWaiting(registration);

// Also check on subsequent page loads when a SW may already be waiting
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});

return registration;
} catch (err) {
console.error('[SW] Registration failed:', err);
return null;
}
}

/** Tells the waiting service worker to skip waiting and take control. */
export function applyUpdate(registration: ServiceWorkerRegistration): void {
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
}
Loading