Skip to content

Commit b7cb37d

Browse files
committed
feat(astro): 📱 Implement PWA support and comprehensive error pages
Add Progressive Web App (PWA) functionality to the Astro application, enabling offline access and installability. * Introduce manifest.json and maskable SVG icons. * Implement sw.js service worker with caching strategies. * Add dedicated error pages: 401, 403, 404, 500, 503, and offline fallback.
1 parent 61b96a1 commit b7cb37d

11 files changed

Lines changed: 739 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Loading
Lines changed: 18 additions & 0 deletions
Loading

apps/astro/public/manifest.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "FOIA Stream",
3+
"short_name": "FOIA Stream",
4+
"description": "Streamline your Freedom of Information Act requests with FOIA Stream",
5+
"start_url": "/",
6+
"display": "standalone",
7+
"background_color": "#0a0c0f",
8+
"theme_color": "#0a0c0f",
9+
"orientation": "portrait-primary",
10+
"scope": "/",
11+
"icons": [
12+
{
13+
"src": "/favicon.svg",
14+
"sizes": "any",
15+
"type": "image/svg+xml",
16+
"purpose": "any"
17+
},
18+
{
19+
"src": "/icons/icon-192x192.svg",
20+
"sizes": "192x192",
21+
"type": "image/svg+xml",
22+
"purpose": "maskable any"
23+
},
24+
{
25+
"src": "/icons/icon-512x512.svg",
26+
"sizes": "512x512",
27+
"type": "image/svg+xml",
28+
"purpose": "maskable any"
29+
}
30+
],
31+
"categories": ["government", "productivity", "utilities"],
32+
"shortcuts": [
33+
{
34+
"name": "New Request",
35+
"short_name": "New Request",
36+
"description": "Create a new FOIA request",
37+
"url": "/requests/new",
38+
"icons": [{ "src": "/favicon.svg", "sizes": "any" }]
39+
},
40+
{
41+
"name": "My Requests",
42+
"short_name": "Requests",
43+
"description": "View your FOIA requests",
44+
"url": "/dashboard",
45+
"icons": [{ "src": "/favicon.svg", "sizes": "any" }]
46+
}
47+
],
48+
"screenshots": [],
49+
"related_applications": [],
50+
"prefer_related_applications": false
51+
}

apps/astro/public/sw.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// @ts-nocheck
2+
/**
3+
* Copyright (c) 2025 Foia Stream
4+
*
5+
* FOIA Stream Service Worker
6+
* Provides offline functionality and caching strategies
7+
*/
8+
9+
const STATIC_CACHE = 'foia-stream-static-v1';
10+
const DYNAMIC_CACHE = 'foia-stream-dynamic-v1';
11+
12+
// Static assets to cache on install
13+
const STATIC_ASSETS = [
14+
'/',
15+
'/offline',
16+
'/manifest.json',
17+
'/favicon.svg',
18+
'/icons/icon-192x192.svg',
19+
'/icons/icon-512x512.svg',
20+
];
21+
22+
// Routes that should always go to network first
23+
const NETWORK_FIRST_ROUTES = ['/api/', '/auth/', '/dashboard', '/requests', '/documents'];
24+
25+
// Install event - cache static assets
26+
self.addEventListener('install', (event) => {
27+
event.waitUntil(
28+
caches.open(STATIC_CACHE).then((cache) => {
29+
console.log('[SW] Caching static assets');
30+
return cache.addAll(STATIC_ASSETS);
31+
}),
32+
);
33+
// Activate immediately
34+
self.skipWaiting();
35+
});
36+
37+
// Activate event - clean up old caches
38+
self.addEventListener('activate', (event) => {
39+
event.waitUntil(
40+
caches.keys().then((cacheNames) => {
41+
return Promise.all(
42+
cacheNames
43+
.filter((name) => {
44+
return (
45+
name.startsWith('foia-stream-') && name !== STATIC_CACHE && name !== DYNAMIC_CACHE
46+
);
47+
})
48+
.map((name) => {
49+
console.log('[SW] Deleting old cache:', name);
50+
return caches.delete(name);
51+
}),
52+
);
53+
}),
54+
);
55+
// Take control immediately
56+
self.clients.claim();
57+
});
58+
59+
// Fetch event - implement caching strategies
60+
self.addEventListener('fetch', (event) => {
61+
const { request } = event;
62+
const url = new URL(request.url);
63+
64+
// Skip non-GET requests
65+
if (request.method !== 'GET') {
66+
return;
67+
}
68+
69+
// Skip chrome-extension and other non-http requests
70+
if (!url.protocol.startsWith('http')) {
71+
return;
72+
}
73+
74+
// Network-first for dynamic routes (API, auth, dashboard, etc.)
75+
if (NETWORK_FIRST_ROUTES.some((route) => url.pathname.startsWith(route))) {
76+
event.respondWith(networkFirst(request));
77+
return;
78+
}
79+
80+
// Cache-first for static assets
81+
if (isStaticAsset(url.pathname)) {
82+
event.respondWith(cacheFirst(request));
83+
return;
84+
}
85+
86+
// Stale-while-revalidate for pages
87+
event.respondWith(staleWhileRevalidate(request));
88+
});
89+
90+
// Check if the request is for a static asset
91+
function isStaticAsset(pathname) {
92+
const staticExtensions = [
93+
'.js',
94+
'.css',
95+
'.png',
96+
'.jpg',
97+
'.jpeg',
98+
'.gif',
99+
'.svg',
100+
'.ico',
101+
'.woff',
102+
'.woff2',
103+
'.ttf',
104+
'.eot',
105+
'.webp',
106+
];
107+
return staticExtensions.some((ext) => pathname.endsWith(ext));
108+
}
109+
110+
// Network-first strategy
111+
async function networkFirst(request) {
112+
try {
113+
const networkResponse = await fetch(request);
114+
// Cache successful responses
115+
if (networkResponse.ok) {
116+
const cache = await caches.open(DYNAMIC_CACHE);
117+
cache.put(request, networkResponse.clone());
118+
}
119+
return networkResponse;
120+
} catch (error) {
121+
// Fall back to cache
122+
const cachedResponse = await caches.match(request);
123+
if (cachedResponse) {
124+
return cachedResponse;
125+
}
126+
// Return offline page for navigation requests
127+
if (request.mode === 'navigate') {
128+
return caches.match('/offline');
129+
}
130+
throw error;
131+
}
132+
}
133+
134+
// Cache-first strategy
135+
async function cacheFirst(request) {
136+
const cachedResponse = await caches.match(request);
137+
if (cachedResponse) {
138+
return cachedResponse;
139+
}
140+
try {
141+
const networkResponse = await fetch(request);
142+
if (networkResponse.ok) {
143+
const cache = await caches.open(STATIC_CACHE);
144+
cache.put(request, networkResponse.clone());
145+
}
146+
return networkResponse;
147+
} catch (error) {
148+
// Return offline fallback if available
149+
if (request.mode === 'navigate') {
150+
return caches.match('/offline');
151+
}
152+
throw error;
153+
}
154+
}
155+
156+
// Stale-while-revalidate strategy
157+
async function staleWhileRevalidate(request) {
158+
const cachedResponse = await caches.match(request);
159+
160+
const networkPromise = fetch(request)
161+
.then((networkResponse) => {
162+
if (networkResponse.ok) {
163+
const cache = caches.open(DYNAMIC_CACHE);
164+
cache.then((c) => c.put(request, networkResponse.clone()));
165+
}
166+
return networkResponse;
167+
})
168+
.catch(() => {
169+
// Network failed, return cached or offline page
170+
if (request.mode === 'navigate') {
171+
return caches.match('/offline');
172+
}
173+
return null;
174+
});
175+
176+
// Return cached response immediately, or wait for network
177+
return cachedResponse || networkPromise;
178+
}
179+
180+
// Handle push notifications (future feature)
181+
self.addEventListener('push', (event) => {
182+
if (!event.data) return;
183+
184+
const data = event.data.json();
185+
const options = {
186+
body: data.body,
187+
icon: '/icons/icon-192x192.svg',
188+
badge: '/favicon.svg',
189+
vibrate: [100, 50, 100],
190+
data: {
191+
url: data.url || '/',
192+
},
193+
actions: data.actions || [],
194+
};
195+
196+
event.waitUntil(self.registration.showNotification(data.title, options));
197+
});
198+
199+
// Handle notification clicks
200+
self.addEventListener('notificationclick', (event) => {
201+
event.notification.close();
202+
203+
const url = event.notification.data?.url || '/';
204+
205+
event.waitUntil(
206+
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
207+
// Focus existing window if available
208+
for (const client of clientList) {
209+
if (client.url === url && 'focus' in client) {
210+
return client.focus();
211+
}
212+
}
213+
// Open new window
214+
if (clients.openWindow) {
215+
return clients.openWindow(url);
216+
}
217+
}),
218+
);
219+
});
220+
221+
// Message handling for cache management
222+
self.addEventListener('message', (event) => {
223+
if (event.data.type === 'SKIP_WAITING') {
224+
self.skipWaiting();
225+
}
226+
227+
if (event.data.type === 'CLEAR_CACHE') {
228+
event.waitUntil(
229+
caches.keys().then((cacheNames) => {
230+
return Promise.all(cacheNames.map((name) => caches.delete(name)));
231+
}),
232+
);
233+
}
234+
});

apps/astro/src/layouts/BaseLayout.astro

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ const {
2323
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
2424
<title>{title} | FOIA Stream</title>
2525

26+
<!-- PWA Manifest -->
27+
<link rel="manifest" href="/manifest.json" />
28+
29+
<!-- Apple Touch Icons -->
30+
<link rel="apple-touch-icon" href="/icons/icon-192x192.svg" />
31+
<meta name="apple-mobile-web-app-capable" content="yes" />
32+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
33+
<meta name="apple-mobile-web-app-title" content="FOIA Stream" />
34+
35+
<!-- Microsoft Tiles -->
36+
<meta name="msapplication-TileColor" content="#0a0c0f" />
37+
<meta name="msapplication-TileImage" content="/icons/icon-192x192.svg" />
38+
2639
<!-- Preconnect to Google Fonts -->
2740
<link rel="preconnect" href="https://fonts.googleapis.com" />
2841
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -33,5 +46,36 @@ const {
3346
<body class="min-h-screen bg-surface-950 text-surface-100 antialiased">
3447
<slot />
3548
<CookieConsent />
49+
50+
<!-- Service Worker Registration -->
51+
<script is:inline>
52+
if ('serviceWorker' in navigator) {
53+
window.addEventListener('load', () => {
54+
navigator.serviceWorker.register('/sw.js')
55+
.then((registration) => {
56+
console.log('[PWA] Service Worker registered:', registration.scope);
57+
58+
// Check for updates
59+
registration.addEventListener('updatefound', () => {
60+
const newWorker = registration.installing;
61+
if (newWorker) {
62+
newWorker.addEventListener('statechange', () => {
63+
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
64+
// New content available, show update prompt
65+
if (window.confirm('New version available! Reload to update?')) {
66+
newWorker.postMessage({ type: 'SKIP_WAITING' });
67+
window.location.reload();
68+
}
69+
}
70+
});
71+
}
72+
});
73+
})
74+
.catch((error) => {
75+
console.error('[PWA] Service Worker registration failed:', error);
76+
});
77+
});
78+
}
79+
</script>
3680
</body>
3781
</html>

0 commit comments

Comments
 (0)