feat: Add PWA support - manifest, service worker, offline banner

This commit is contained in:
BizzleBot
2026-02-16 21:02:15 +00:00
parent 70ed672381
commit 0739f87f73
8 changed files with 163 additions and 0 deletions
+9
View File
@@ -5,6 +5,8 @@
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌊</text></svg>" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌊</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1" />
<meta name="theme-color" content="#0ea5e9" /> <meta name="theme-color" content="#0ea5e9" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Coastal Contracting Timesheet</title> <title>Coastal Contracting Timesheet</title>
@@ -12,5 +14,12 @@
<body class="bg-gray-50 dark:bg-gray-950 antialiased"> <body class="bg-gray-50 dark:bg-gray-950 antialiased">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body> </body>
</html> </html>
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0ea5e9"/>
<stop offset="100%" stop-color="#0369a1"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="28" fill="url(#bg)"/>
<text x="96" y="108" text-anchor="middle" font-size="100" fill="white" font-family="serif">🌊</text>
</svg>

After

Width:  |  Height:  |  Size: 453 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0ea5e9"/>
<stop offset="100%" stop-color="#0369a1"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="76" fill="url(#bg)"/>
<text x="256" y="290" text-anchor="middle" font-size="260" fill="white" font-family="serif">🌊</text>
</svg>

After

Width:  |  Height:  |  Size: 454 B

+24
View File
@@ -0,0 +1,24 @@
{
"name": "Coastal Contracting Timesheet",
"short_name": "Timesheet",
"description": "Employee timesheet management for Coastal Contracting",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0ea5e9",
"orientation": "any",
"icons": [
{
"src": "/icons/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+78
View File
@@ -0,0 +1,78 @@
const CACHE_NAME = 'coastal-timesheet-v1';
const STATIC_ASSETS = [
'/',
'/manifest.json',
'/icons/icon-192.svg',
'/icons/icon-512.svg',
];
// Install: cache shell
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// Activate: clean old caches
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
// Fetch: network-first for API, cache-first for assets
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = new URL(request.url);
// Skip non-GET
if (request.method !== 'GET') return;
// API calls: network-first with offline fallback
if (url.pathname.startsWith('/api/')) {
e.respondWith(
fetch(request)
.then((res) => {
// Cache successful GET API responses for offline use
if (res.ok) {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return res;
})
.catch(() => caches.match(request).then((cached) => cached || offlineResponse()))
);
return;
}
// Static assets: cache-first
e.respondWith(
caches.match(request).then((cached) => {
if (cached) {
// Background refresh
fetch(request).then((res) => {
if (res.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, res));
}).catch(() => {});
return cached;
}
return fetch(request).then((res) => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return res;
}).catch(() => caches.match('/'));
})
);
});
function offlineResponse() {
return new Response(
JSON.stringify({ error: 'offline', message: 'You are currently offline. Data will sync when connection is restored.' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
+12
View File
@@ -0,0 +1,12 @@
import { useOnlineStatus } from '../hooks/useOnlineStatus';
export default function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="bg-amber-500 text-white text-center text-sm py-1.5 px-4 font-medium sticky top-0 z-50">
⚡ You're offline — viewing cached data. Changes will sync when reconnected.
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
return isOnline;
}
+2
View File
@@ -7,6 +7,7 @@ import Login from './pages/Login';
import Timesheet from './pages/Timesheet'; import Timesheet from './pages/Timesheet';
import History from './pages/History'; import History from './pages/History';
import Admin from './pages/Admin'; import Admin from './pages/Admin';
import OfflineBanner from './components/OfflineBanner';
import './index.css'; import './index.css';
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
@@ -40,6 +41,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<OfflineBanner />
<AppRoutes /> <AppRoutes />
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>