- 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>
432 lines
13 KiB
JavaScript
432 lines
13 KiB
JavaScript
// 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'); |