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:
2025-09-28 22:20:44 +00:00
parent 83a981cabc
commit a01b2490dc
15 changed files with 2730 additions and 7 deletions

499
lib/offline-storage.ts Normal file
View File

@@ -0,0 +1,499 @@
// IndexedDB wrapper for offline Bible storage
export interface BibleVersion {
id: string
name: string
abbreviation: string
language: string
isDefault?: boolean
downloadedAt?: string
size?: number
books?: BibleBook[]
}
export interface BibleBook {
id: string
name: string
abbreviation: string
orderNum: number
testament: string
chaptersCount: number
chapters?: BibleChapter[]
}
export interface BibleChapter {
id: string
bookId: string
chapterNum: number
verseCount: number
verses: BibleVerse[]
}
export interface BibleVerse {
id: string
chapterId: string
verseNum: number
text: string
}
export interface DownloadProgress {
versionId: string
status: 'pending' | 'downloading' | 'completed' | 'failed'
progress: number
totalBooks: number
downloadedBooks: number
totalChapters: number
downloadedChapters: number
startedAt: string
completedAt?: string
error?: string
}
class OfflineStorage {
private db: IDBDatabase | null = null
private readonly dbName = 'BibleStorage'
private readonly dbVersion = 1
async init(): Promise<void> {
if (this.db) return
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
// Bible versions store
if (!db.objectStoreNames.contains('versions')) {
const versionsStore = db.createObjectStore('versions', { keyPath: 'id' })
versionsStore.createIndex('language', 'language', { unique: false })
versionsStore.createIndex('abbreviation', 'abbreviation', { unique: false })
}
// Bible books store
if (!db.objectStoreNames.contains('books')) {
const booksStore = db.createObjectStore('books', { keyPath: 'id' })
booksStore.createIndex('versionId', 'versionId', { unique: false })
booksStore.createIndex('orderNum', 'orderNum', { unique: false })
}
// Bible chapters store
if (!db.objectStoreNames.contains('chapters')) {
const chaptersStore = db.createObjectStore('chapters', { keyPath: 'id' })
chaptersStore.createIndex('bookId', 'bookId', { unique: false })
chaptersStore.createIndex('versionBookChapter', ['versionId', 'bookId', 'chapterNum'], { unique: true })
}
// Download progress store
if (!db.objectStoreNames.contains('downloads')) {
const downloadsStore = db.createObjectStore('downloads', { keyPath: 'versionId' })
downloadsStore.createIndex('status', 'status', { unique: false })
}
// Settings store
if (!db.objectStoreNames.contains('settings')) {
const settingsStore = db.createObjectStore('settings', { keyPath: 'key' })
}
}
})
}
// Version management
async saveVersion(version: BibleVersion): Promise<void> {
await this.init()
return this.performTransaction('versions', 'readwrite', (store) => {
const versionsStore = store as IDBObjectStore
versionsStore.put({
...version,
downloadedAt: new Date().toISOString()
})
})
}
async getVersion(versionId: string): Promise<BibleVersion | null> {
await this.init()
return this.performTransaction('versions', 'readonly', (store) => {
const versionsStore = store as IDBObjectStore
return versionsStore.get(versionId)
})
}
async getAllVersions(): Promise<BibleVersion[]> {
await this.init()
return this.performTransaction('versions', 'readonly', (store) => {
const versionsStore = store as IDBObjectStore
return versionsStore.getAll()
})
}
async deleteVersion(versionId: string): Promise<void> {
await this.init()
return this.performTransaction(['versions', 'books', 'chapters', 'downloads'], 'readwrite', (stores) => {
const storeArray = stores as IDBObjectStore[]
const [versionsStore, booksStore, chaptersStore, downloadsStore] = storeArray
// Delete version
versionsStore.delete(versionId)
// Delete all books for this version
const booksIndex = booksStore.index('versionId')
const booksRequest = booksIndex.getAllKeys(versionId)
booksRequest.onsuccess = () => {
booksRequest.result.forEach((bookId: any) => {
booksStore.delete(bookId)
// Delete all chapters for each book
const chaptersIndex = chaptersStore.index('bookId')
const chaptersRequest = chaptersIndex.getAllKeys(bookId)
chaptersRequest.onsuccess = () => {
chaptersRequest.result.forEach((chapterId: any) => {
chaptersStore.delete(chapterId)
})
}
})
}
// Delete download progress
downloadsStore.delete(versionId)
})
}
// Chapter management
async saveChapter(versionId: string, bookId: string, chapter: BibleChapter): Promise<void> {
await this.init()
return this.performTransaction('chapters', 'readwrite', (store) => {
const chaptersStore = store as IDBObjectStore
chaptersStore.put({
...chapter,
versionId,
bookId
})
})
}
async getChapter(versionId: string, bookId: string, chapterNum: number): Promise<BibleChapter | null> {
await this.init()
return this.performTransaction('chapters', 'readonly', (store) => {
const chaptersStore = store as IDBObjectStore
const index = chaptersStore.index('versionBookChapter')
return index.get([versionId, bookId, chapterNum])
})
}
async getChaptersForBook(bookId: string): Promise<BibleChapter[]> {
await this.init()
return this.performTransaction('chapters', 'readonly', (store) => {
const chaptersStore = store as IDBObjectStore
const index = chaptersStore.index('bookId')
return index.getAll(bookId)
})
}
// Book management
async saveBook(versionId: string, book: BibleBook): Promise<void> {
await this.init()
return this.performTransaction('books', 'readwrite', (store) => {
const booksStore = store as IDBObjectStore
booksStore.put({
...book,
versionId
})
})
}
async getBooksForVersion(versionId: string): Promise<BibleBook[]> {
await this.init()
return this.performTransaction('books', 'readonly', (store) => {
const booksStore = store as IDBObjectStore
const index = booksStore.index('versionId')
return index.getAll(versionId)
})
}
// Download progress management
async saveDownloadProgress(progress: DownloadProgress): Promise<void> {
await this.init()
return this.performTransaction('downloads', 'readwrite', (store) => {
const downloadsStore = store as IDBObjectStore
downloadsStore.put(progress)
})
}
async getDownloadProgress(versionId: string): Promise<DownloadProgress | null> {
await this.init()
return this.performTransaction('downloads', 'readonly', (store) => {
const downloadsStore = store as IDBObjectStore
return downloadsStore.get(versionId)
})
}
async getAllDownloads(): Promise<DownloadProgress[]> {
await this.init()
return this.performTransaction('downloads', 'readonly', (store) => {
const downloadsStore = store as IDBObjectStore
return downloadsStore.getAll()
})
}
async deleteDownloadProgress(versionId: string): Promise<void> {
await this.init()
return this.performTransaction('downloads', 'readwrite', (store) => {
const downloadsStore = store as IDBObjectStore
downloadsStore.delete(versionId)
})
}
// Settings management
async saveSetting(key: string, value: any): Promise<void> {
await this.init()
return this.performTransaction('settings', 'readwrite', (store) => {
const settingsStore = store as IDBObjectStore
settingsStore.put({ key, value, updatedAt: new Date().toISOString() })
})
}
async getSetting(key: string): Promise<any> {
await this.init()
const result = await this.performTransaction('settings', 'readonly', (store) => {
const settingsStore = store as IDBObjectStore
return settingsStore.get(key)
})
return result?.value
}
// Storage info
async getStorageInfo(): Promise<{ used: number; quota: number; percentage: number }> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate()
const used = estimate.usage || 0
const quota = estimate.quota || 0
const percentage = quota > 0 ? (used / quota) * 100 : 0
return { used, quota, percentage }
}
return { used: 0, quota: 0, percentage: 0 }
}
// Check if version is available offline
async isVersionAvailableOffline(versionId: string): Promise<boolean> {
const version = await this.getVersion(versionId)
return !!version
}
// Get offline reading statistics
async getOfflineStats(): Promise<{
totalVersions: number
totalBooks: number
totalChapters: number
storageUsed: number
lastSyncDate?: string
}> {
const versions = await this.getAllVersions()
let totalBooks = 0
let totalChapters = 0
for (const version of versions) {
const books = await this.getBooksForVersion(version.id)
totalBooks += books.length
for (const book of books) {
const chapters = await this.getChaptersForBook(book.id)
totalChapters += chapters.length
}
}
const storageInfo = await this.getStorageInfo()
const lastSyncDate = await this.getSetting('lastSyncDate')
return {
totalVersions: versions.length,
totalBooks,
totalChapters,
storageUsed: storageInfo.used,
lastSyncDate
}
}
// Helper method for transactions
private performTransaction<T>(
storeNames: string | string[],
mode: IDBTransactionMode,
operation: (store: IDBObjectStore | IDBObjectStore[]) => IDBRequest<T> | Promise<T> | T
): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.db) {
reject(new Error('Database not initialized'))
return
}
const transaction = this.db.transaction(storeNames, mode)
transaction.onerror = () => reject(transaction.error)
let stores: IDBObjectStore | IDBObjectStore[]
if (typeof storeNames === 'string') {
stores = transaction.objectStore(storeNames)
} else {
stores = storeNames.map(name => transaction.objectStore(name))
}
try {
const result = operation(stores)
if (result instanceof Promise) {
result.then(resolve).catch(reject)
} else if (result && typeof result === 'object' && 'onsuccess' in result) {
const request = result as IDBRequest<T>
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
} else {
resolve(result as T)
}
} catch (error) {
reject(error)
}
})
}
// Clear all data
async clearAll(): Promise<void> {
await this.init()
return this.performTransaction(['versions', 'books', 'chapters', 'downloads', 'settings'], 'readwrite', (stores) => {
const storeArray = stores as IDBObjectStore[]
storeArray.forEach((store: IDBObjectStore) => store.clear())
})
}
}
// Export singleton instance
export const offlineStorage = new OfflineStorage()
// Bible download manager
export class BibleDownloadManager {
private downloadQueue: Set<string> = new Set()
async downloadVersion(versionId: string, onProgress?: (progress: DownloadProgress) => void): Promise<void> {
if (this.downloadQueue.has(versionId)) {
throw new Error('Version is already being downloaded')
}
this.downloadQueue.add(versionId)
try {
// Initialize progress
const progress: DownloadProgress = {
versionId,
status: 'downloading',
progress: 0,
totalBooks: 0,
downloadedBooks: 0,
totalChapters: 0,
downloadedChapters: 0,
startedAt: new Date().toISOString()
}
await offlineStorage.saveDownloadProgress(progress)
onProgress?.(progress)
// Get version info and books
console.log(`[Download] Starting download for version: ${versionId}`)
const versionResponse = await fetch(`/api/bible/books?version=${versionId}`)
if (!versionResponse.ok) {
throw new Error('Failed to fetch version books')
}
const versionData = await versionResponse.json()
const { version, books } = versionData
progress.totalBooks = books.length
await offlineStorage.saveDownloadProgress(progress)
onProgress?.(progress)
// Save version info
await offlineStorage.saveVersion(version)
// Calculate total chapters
progress.totalChapters = books.reduce((sum: number, book: any) => sum + book.chaptersCount, 0)
await offlineStorage.saveDownloadProgress(progress)
onProgress?.(progress)
// Download each book and its chapters
for (const book of books) {
console.log(`[Download] Downloading book: ${book.name}`)
await offlineStorage.saveBook(versionId, book)
progress.downloadedBooks++
// Download all chapters for this book
for (let chapterNum = 1; chapterNum <= book.chaptersCount; chapterNum++) {
const chapterResponse = await fetch(`/api/bible/chapter?book=${book.id}&chapter=${chapterNum}&version=${versionId}`)
if (chapterResponse.ok) {
const chapterData = await chapterResponse.json()
await offlineStorage.saveChapter(versionId, book.id, chapterData.chapter)
}
progress.downloadedChapters++
progress.progress = Math.round((progress.downloadedChapters / progress.totalChapters) * 100)
await offlineStorage.saveDownloadProgress(progress)
onProgress?.(progress)
// Small delay to prevent overwhelming the API
await new Promise(resolve => setTimeout(resolve, 50))
}
}
// Mark as completed
progress.status = 'completed'
progress.completedAt = new Date().toISOString()
progress.progress = 100
await offlineStorage.saveDownloadProgress(progress)
onProgress?.(progress)
console.log(`[Download] Version ${versionId} downloaded successfully`)
} catch (error) {
console.error(`[Download] Failed to download version ${versionId}:`, error)
const progress = await offlineStorage.getDownloadProgress(versionId)
if (progress) {
progress.status = 'failed'
progress.error = error instanceof Error ? error.message : 'Unknown error'
await offlineStorage.saveDownloadProgress(progress)
onProgress?.(progress)
}
throw error
} finally {
this.downloadQueue.delete(versionId)
}
}
async deleteVersion(versionId: string): Promise<void> {
await offlineStorage.deleteVersion(versionId)
console.log(`[Download] Version ${versionId} deleted from offline storage`)
}
isDownloading(versionId: string): boolean {
return this.downloadQueue.has(versionId)
}
async getDownloadedVersions(): Promise<BibleVersion[]> {
return offlineStorage.getAllVersions()
}
async getStorageInfo(): Promise<{ used: number; quota: number; percentage: number }> {
return offlineStorage.getStorageInfo()
}
}
// Export singleton instance
export const bibleDownloadManager = new BibleDownloadManager()