- 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>
499 lines
15 KiB
TypeScript
499 lines
15 KiB
TypeScript
// 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() |