Implement comprehensive PWA with offline Bible reading capabilities
- Add Web App Manifest with app metadata, icons, and installation support - Create Service Worker with intelligent caching strategies for Bible content, static assets, and dynamic content - Implement IndexedDB-based offline storage system for Bible versions, books, chapters, and verses - Add offline download manager component for browsing and downloading Bible versions - Create offline Bible reader component for seamless offline reading experience - Integrate PWA install prompt with platform-specific instructions - Add offline reading interface to existing Bible reader with download buttons - Create dedicated offline page with tabbed interface for reading and downloading - Add PWA and offline-related translations for English and Romanian locales - Implement background sync for Bible downloads and cache management - Add storage usage monitoring and management utilities - Ensure SSR-safe implementation with dynamic imports for client-side components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
432
public/sw.js
Normal file
432
public/sw.js
Normal file
@@ -0,0 +1,432 @@
|
||||
// Biblical Guide Service Worker
|
||||
const CACHE_NAME = 'biblical-guide-v1.0.0';
|
||||
const STATIC_CACHE = 'biblical-guide-static-v1.0.0';
|
||||
const DYNAMIC_CACHE = 'biblical-guide-dynamic-v1.0.0';
|
||||
const BIBLE_CACHE = 'biblical-guide-bible-v1.0.0';
|
||||
|
||||
// Static resources that should be cached immediately
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/biblical-guide-og-image.png',
|
||||
'/offline.html', // We'll create this fallback page
|
||||
];
|
||||
|
||||
// Bible API endpoints that should be cached
|
||||
const BIBLE_API_PATTERNS = [
|
||||
/\/api\/bible\/versions/,
|
||||
/\/api\/bible\/books/,
|
||||
/\/api\/bible\/chapter/,
|
||||
/\/api\/bible\/verses/,
|
||||
];
|
||||
|
||||
// Dynamic content that should be cached with network-first strategy
|
||||
const DYNAMIC_PATTERNS = [
|
||||
/\/api\/daily-verse/,
|
||||
/\/api\/search/,
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
caches.open(STATIC_CACHE).then((cache) => {
|
||||
console.log('[SW] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS.map(url => new Request(url, {credentials: 'same-origin'})));
|
||||
}),
|
||||
caches.open(BIBLE_CACHE).then(() => {
|
||||
console.log('[SW] Bible cache initialized');
|
||||
})
|
||||
]).then(() => {
|
||||
console.log('[SW] Installation complete');
|
||||
// Skip waiting to activate immediately
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
// Clean up old caches
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== STATIC_CACHE &&
|
||||
cacheName !== DYNAMIC_CACHE &&
|
||||
cacheName !== BIBLE_CACHE &&
|
||||
cacheName !== CACHE_NAME) {
|
||||
console.log('[SW] Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
}),
|
||||
// Take control of all pages immediately
|
||||
self.clients.claim()
|
||||
]).then(() => {
|
||||
console.log('[SW] Activation complete');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - handle all network requests
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const { url, method } = request;
|
||||
|
||||
// Only handle GET requests
|
||||
if (method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Bible content downloads (store in Bible cache)
|
||||
if (BIBLE_API_PATTERNS.some(pattern => pattern.test(url))) {
|
||||
event.respondWith(handleBibleRequest(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle dynamic content (network-first with cache fallback)
|
||||
if (DYNAMIC_PATTERNS.some(pattern => pattern.test(url))) {
|
||||
event.respondWith(handleDynamicRequest(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle static assets (cache-first)
|
||||
if (isStaticAsset(url)) {
|
||||
event.respondWith(handleStaticRequest(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation requests (HTML pages)
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(handleNavigationRequest(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: network-first for everything else
|
||||
event.respondWith(handleDefaultRequest(request));
|
||||
});
|
||||
|
||||
// Bible content strategy: Cache-first with network update
|
||||
async function handleBibleRequest(request) {
|
||||
const cache = await caches.open(BIBLE_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
console.log('[SW] Serving Bible content from cache:', request.url);
|
||||
// Update cache in background
|
||||
updateBibleCache(request, cache);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[SW] Fetching Bible content from network:', request.url);
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
// Clone before caching
|
||||
const responseClone = response.clone();
|
||||
await cache.put(request, responseClone);
|
||||
console.log('[SW] Bible content cached:', request.url);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[SW] Bible content fetch failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Bible cache in background
|
||||
async function updateBibleCache(request, cache) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
console.log('[SW] Bible cache updated:', request.url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[SW] Background Bible cache update failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic content strategy: Network-first with cache fallback
|
||||
async function handleDynamicRequest(request) {
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
|
||||
try {
|
||||
console.log('[SW] Fetching dynamic content from network:', request.url);
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
// Cache successful responses
|
||||
await cache.put(request, response.clone());
|
||||
console.log('[SW] Dynamic content cached:', request.url);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Network failed, checking cache for:', request.url);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) {
|
||||
console.log('[SW] Serving dynamic content from cache:', request.url);
|
||||
return cached;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Static assets strategy: Cache-first
|
||||
async function handleStaticRequest(request) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
console.log('[SW] Serving static asset from cache:', request.url);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[SW] Fetching static asset from network:', request.url);
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
console.log('[SW] Static asset cached:', request.url);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[SW] Static asset fetch failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation strategy: Network-first with offline fallback
|
||||
async function handleNavigationRequest(request) {
|
||||
try {
|
||||
console.log('[SW] Fetching navigation from network:', request.url);
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
// Cache successful navigation responses
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Navigation network failed, checking cache:', request.url);
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
console.log('[SW] Serving navigation from cache:', request.url);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Return offline fallback page
|
||||
console.log('[SW] Serving offline fallback page');
|
||||
const offlineCache = await caches.open(STATIC_CACHE);
|
||||
const offlinePage = await offlineCache.match('/offline.html');
|
||||
return offlinePage || new Response('Offline - Please check your connection', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default strategy: Network-first
|
||||
async function handleDefaultRequest(request) {
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch (error) {
|
||||
console.log('[SW] Default request failed:', request.url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if URL is a static asset
|
||||
function isStaticAsset(url) {
|
||||
return url.includes('/_next/') ||
|
||||
url.includes('/icon-') ||
|
||||
url.includes('/manifest.json') ||
|
||||
url.includes('/biblical-guide-og-image.png') ||
|
||||
url.includes('.css') ||
|
||||
url.includes('.js') ||
|
||||
url.includes('.woff') ||
|
||||
url.includes('.woff2');
|
||||
}
|
||||
|
||||
// Background sync for Bible downloads
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('[SW] Background sync triggered:', event.tag);
|
||||
|
||||
if (event.tag === 'download-bible-version') {
|
||||
event.waitUntil(handleBibleDownloadSync());
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Bible version download in background
|
||||
async function handleBibleDownloadSync() {
|
||||
try {
|
||||
console.log('[SW] Processing background Bible download');
|
||||
// Get pending downloads from IndexedDB
|
||||
const pendingDownloads = await getPendingBibleDownloads();
|
||||
|
||||
for (const download of pendingDownloads) {
|
||||
await downloadBibleVersion(download);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SW] Background Bible download failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending downloads from IndexedDB
|
||||
async function getPendingBibleDownloads() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('BibleStorage', 1);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction(['downloads'], 'readonly');
|
||||
const store = transaction.objectStore('downloads');
|
||||
const getAllRequest = store.getAll();
|
||||
|
||||
getAllRequest.onsuccess = () => {
|
||||
resolve(getAllRequest.result.filter(d => d.status === 'pending'));
|
||||
};
|
||||
|
||||
getAllRequest.onerror = () => reject(getAllRequest.error);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Download a Bible version
|
||||
async function downloadBibleVersion(download) {
|
||||
try {
|
||||
console.log('[SW] Downloading Bible version:', download.versionId);
|
||||
|
||||
// Download books list
|
||||
const booksResponse = await fetch(`/api/bible/books?version=${download.versionId}`);
|
||||
if (!booksResponse.ok) throw new Error('Failed to fetch books');
|
||||
|
||||
const books = await booksResponse.json();
|
||||
|
||||
// Download each chapter
|
||||
for (const book of books.books) {
|
||||
for (let chapter = 1; chapter <= book.chaptersCount; chapter++) {
|
||||
const chapterUrl = `/api/bible/chapter?book=${book.id}&chapter=${chapter}&version=${download.versionId}`;
|
||||
await fetch(chapterUrl); // This will be cached by the fetch handler
|
||||
}
|
||||
}
|
||||
|
||||
// Mark download as complete
|
||||
await updateDownloadStatus(download.versionId, 'completed');
|
||||
|
||||
console.log('[SW] Bible version download completed:', download.versionId);
|
||||
} catch (error) {
|
||||
console.error('[SW] Bible version download failed:', error);
|
||||
await updateDownloadStatus(download.versionId, 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update download status in IndexedDB
|
||||
async function updateDownloadStatus(versionId, status) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('BibleStorage', 1);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction(['downloads'], 'readwrite');
|
||||
const store = transaction.objectStore('downloads');
|
||||
|
||||
const getRequest = store.get(versionId);
|
||||
getRequest.onsuccess = () => {
|
||||
const download = getRequest.result;
|
||||
if (download) {
|
||||
download.status = status;
|
||||
download.updatedAt = new Date().toISOString();
|
||||
const putRequest = store.put(download);
|
||||
putRequest.onsuccess = () => resolve();
|
||||
putRequest.onerror = () => reject(putRequest.error);
|
||||
} else {
|
||||
resolve(); // Download not found, ignore
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Message handling for communication with the main thread
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('[SW] Message received:', event.data);
|
||||
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
||||
clearAllCaches().then(() => {
|
||||
event.ports[0].postMessage({success: true});
|
||||
});
|
||||
}
|
||||
|
||||
if (event.data && event.data.type === 'CACHE_BIBLE_VERSION') {
|
||||
const { versionId } = event.data;
|
||||
cacheBibleVersion(versionId).then(() => {
|
||||
event.ports[0].postMessage({success: true, versionId});
|
||||
}).catch((error) => {
|
||||
event.ports[0].postMessage({success: false, error: error.message, versionId});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clear all caches
|
||||
async function clearAllCaches() {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
console.log('[SW] All caches cleared');
|
||||
}
|
||||
|
||||
// Cache a specific Bible version
|
||||
async function cacheBibleVersion(versionId) {
|
||||
console.log('[SW] Caching Bible version:', versionId);
|
||||
|
||||
try {
|
||||
// Download and cache all content for this version
|
||||
const booksResponse = await fetch(`/api/bible/books?version=${versionId}`);
|
||||
if (!booksResponse.ok) throw new Error('Failed to fetch books');
|
||||
|
||||
const books = await booksResponse.json();
|
||||
const cache = await caches.open(BIBLE_CACHE);
|
||||
|
||||
// Cache books list
|
||||
await cache.put(`/api/bible/books?version=${versionId}`, booksResponse.clone());
|
||||
|
||||
// Cache each chapter
|
||||
for (const book of books.books) {
|
||||
for (let chapter = 1; chapter <= book.chaptersCount; chapter++) {
|
||||
const chapterUrl = `/api/bible/chapter?book=${book.id}&chapter=${chapter}&version=${versionId}`;
|
||||
const chapterResponse = await fetch(chapterUrl);
|
||||
if (chapterResponse.ok) {
|
||||
await cache.put(chapterUrl, chapterResponse.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SW] Bible version cached successfully:', versionId);
|
||||
} catch (error) {
|
||||
console.error('[SW] Failed to cache Bible version:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SW] Service worker script loaded');
|
||||
Reference in New Issue
Block a user