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:
499
lib/offline-storage.ts
Normal file
499
lib/offline-storage.ts
Normal 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()
|
||||
Reference in New Issue
Block a user