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:
@@ -4,6 +4,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import { useAuth } from '@/hooks/use-auth'
|
import { useAuth } from '@/hooks/use-auth'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
|
import { OfflineDownloadManager } from '@/components/bible/offline-download-manager'
|
||||||
|
import { OfflineBibleReader } from '@/components/bible/offline-bible-reader'
|
||||||
|
import { InstallPrompt, useInstallPrompt } from '@/components/pwa/install-prompt'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -69,7 +72,10 @@ import {
|
|||||||
MenuBook,
|
MenuBook,
|
||||||
Visibility,
|
Visibility,
|
||||||
Speed,
|
Speed,
|
||||||
Chat
|
Chat,
|
||||||
|
CloudDownload,
|
||||||
|
WifiOff,
|
||||||
|
Storage
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
|
|
||||||
interface BibleVerse {
|
interface BibleVerse {
|
||||||
@@ -149,6 +155,11 @@ export default function BibleReaderNew() {
|
|||||||
const [showScrollTop, setShowScrollTop] = useState(false)
|
const [showScrollTop, setShowScrollTop] = useState(false)
|
||||||
const [previousVerses, setPreviousVerses] = useState<BibleVerse[]>([]) // Keep previous content during loading
|
const [previousVerses, setPreviousVerses] = useState<BibleVerse[]>([]) // Keep previous content during loading
|
||||||
|
|
||||||
|
// Offline/PWA state
|
||||||
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [isOfflineMode, setIsOfflineMode] = useState(false)
|
||||||
|
const [offlineDialogOpen, setOfflineDialogOpen] = useState(false)
|
||||||
|
|
||||||
// Bookmark state
|
// Bookmark state
|
||||||
const [isChapterBookmarked, setIsChapterBookmarked] = useState(false)
|
const [isChapterBookmarked, setIsChapterBookmarked] = useState(false)
|
||||||
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
|
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
|
||||||
@@ -177,6 +188,9 @@ export default function BibleReaderNew() {
|
|||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const verseRefs = useRef<{[key: number]: HTMLDivElement}>({})
|
const verseRefs = useRef<{[key: number]: HTMLDivElement}>({})
|
||||||
|
|
||||||
|
// PWA install prompt
|
||||||
|
const { canInstall, isInstalled, showInstallPrompt } = useInstallPrompt()
|
||||||
|
|
||||||
// Load user preferences from localStorage
|
// Load user preferences from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedPrefs = localStorage.getItem('bibleReaderPreferences')
|
const savedPrefs = localStorage.getItem('bibleReaderPreferences')
|
||||||
@@ -235,6 +249,39 @@ export default function BibleReaderNew() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll)
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Online/offline detection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
setIsOnline(true)
|
||||||
|
if (isOfflineMode) {
|
||||||
|
// Show notification that connection is restored
|
||||||
|
console.log('Connection restored, you can now access all features')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
setIsOnline(false)
|
||||||
|
console.log('You are now offline. Only downloaded content is available.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
setIsOnline(navigator.onLine)
|
||||||
|
|
||||||
|
// Check for offline mode preference
|
||||||
|
const offlineParam = new URLSearchParams(window.location.search).get('offline')
|
||||||
|
if (offlineParam === 'true') {
|
||||||
|
setIsOfflineMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [isOfflineMode])
|
||||||
|
|
||||||
// Fetch versions based on showAllVersions state and locale
|
// Fetch versions based on showAllVersions state and locale
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVersionsLoading(true)
|
setVersionsLoading(true)
|
||||||
@@ -1093,6 +1140,24 @@ export default function BibleReaderNew() {
|
|||||||
<Share />
|
<Share />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Offline Downloads">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setOfflineDialogOpen(true)}
|
||||||
|
sx={{ color: !isOnline ? 'warning.main' : 'inherit' }}
|
||||||
|
>
|
||||||
|
{isOnline ? <CloudDownload /> : <WifiOff />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{canInstall && !isInstalled && (
|
||||||
|
<Tooltip title="Install App">
|
||||||
|
<IconButton size="small" onClick={showInstallPrompt}>
|
||||||
|
<Storage />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
@@ -1386,6 +1451,38 @@ export default function BibleReaderNew() {
|
|||||||
{/* Settings Dialog */}
|
{/* Settings Dialog */}
|
||||||
{renderSettings()}
|
{renderSettings()}
|
||||||
|
|
||||||
|
{/* Offline Downloads Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={offlineDialogOpen}
|
||||||
|
onClose={() => setOfflineDialogOpen(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isMobile}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Storage color="primary" />
|
||||||
|
Offline Bible Downloads
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ p: 0 }}>
|
||||||
|
<OfflineDownloadManager
|
||||||
|
availableVersions={versions}
|
||||||
|
onVersionDownloaded={(versionId) => {
|
||||||
|
console.log(`Version ${versionId} downloaded successfully`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOfflineDialogOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* PWA Install Prompt */}
|
||||||
|
<InstallPrompt autoShow={true} />
|
||||||
|
|
||||||
{/* Copy Feedback */}
|
{/* Copy Feedback */}
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={copyFeedback.open}
|
open={copyFeedback.open}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AuthProvider } from '@/components/auth/auth-provider'
|
|||||||
import { Navigation } from '@/components/layout/navigation'
|
import { Navigation } from '@/components/layout/navigation'
|
||||||
import { Footer } from '@/components/layout/footer'
|
import { Footer } from '@/components/layout/footer'
|
||||||
import FloatingChat from '@/components/chat/floating-chat'
|
import FloatingChat from '@/components/chat/floating-chat'
|
||||||
|
import { ServiceWorkerProvider } from '@/components/pwa/service-worker-provider'
|
||||||
import { merriweather, lato } from '@/lib/fonts'
|
import { merriweather, lato } from '@/lib/fonts'
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
@@ -53,6 +54,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||||||
description: t('twitterDescription'),
|
description: t('twitterDescription'),
|
||||||
images: [ogImageUrl],
|
images: [ogImageUrl],
|
||||||
},
|
},
|
||||||
|
manifest: '/manifest.json',
|
||||||
other: {
|
other: {
|
||||||
'application/ld+json': JSON.stringify({
|
'application/ld+json': JSON.stringify({
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -119,12 +121,14 @@ export default async function LocaleLayout({
|
|||||||
<body className={`${merriweather.variable} ${lato.variable}`}>
|
<body className={`${merriweather.variable} ${lato.variable}`}>
|
||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
<MuiThemeProvider>
|
<MuiThemeProvider>
|
||||||
<AuthProvider>
|
<ServiceWorkerProvider>
|
||||||
<Navigation />
|
<AuthProvider>
|
||||||
{children}
|
<Navigation />
|
||||||
<Footer />
|
{children}
|
||||||
<FloatingChat />
|
<Footer />
|
||||||
</AuthProvider>
|
<FloatingChat />
|
||||||
|
</AuthProvider>
|
||||||
|
</ServiceWorkerProvider>
|
||||||
</MuiThemeProvider>
|
</MuiThemeProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
111
app/[locale]/offline/page.tsx
Normal file
111
app/[locale]/offline/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect } from 'react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
// Dynamic imports to avoid SSR issues with navigator/window
|
||||||
|
const OfflineBibleReader = dynamic(
|
||||||
|
() => import('@/components/bible/offline-bible-reader').then(mod => ({ default: mod.OfflineBibleReader })),
|
||||||
|
{ ssr: false, loading: () => <div>Loading offline reader...</div> }
|
||||||
|
)
|
||||||
|
|
||||||
|
const OfflineDownloadManager = dynamic(
|
||||||
|
() => import('@/components/bible/offline-download-manager').then(mod => ({ default: mod.OfflineDownloadManager })),
|
||||||
|
{ ssr: false, loading: () => <div>Loading download manager...</div> }
|
||||||
|
)
|
||||||
|
|
||||||
|
const InstallPrompt = dynamic(
|
||||||
|
() => import('@/components/pwa/install-prompt').then(mod => ({ default: mod.InstallPrompt })),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Paper
|
||||||
|
} from '@mui/material'
|
||||||
|
|
||||||
|
interface BibleVersion {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
abbreviation: string
|
||||||
|
language: string
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfflinePageContent() {
|
||||||
|
const [tabValue, setTabValue] = useState(0)
|
||||||
|
const [availableVersions, setAvailableVersions] = useState<BibleVersion[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch available Bible versions for download
|
||||||
|
fetch('/api/bible/versions?all=true&limit=50')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success && data.versions) {
|
||||||
|
setAvailableVersions(data.versions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to fetch versions:', err))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTabValue(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 3 }}>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Offline Bible Reading
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Download Bible versions for offline reading and access them without an internet connection.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper sx={{ mb: 3 }}>
|
||||||
|
<Tabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
variant="fullWidth"
|
||||||
|
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Tab label="Offline Reader" />
|
||||||
|
<Tab label="Download Manager" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box sx={{ p: 0 }}>
|
||||||
|
{tabValue === 0 && (
|
||||||
|
<OfflineBibleReader
|
||||||
|
onRequestDownload={() => setTabValue(1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tabValue === 1 && (
|
||||||
|
<OfflineDownloadManager
|
||||||
|
availableVersions={availableVersions}
|
||||||
|
onVersionDownloaded={() => setTabValue(0)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* PWA Install Prompt */}
|
||||||
|
<InstallPrompt autoShow={false} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OfflinePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
Loading offline reading interface...
|
||||||
|
</Box>
|
||||||
|
}>
|
||||||
|
<OfflinePageContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
371
components/bible/offline-bible-reader.tsx
Normal file
371
components/bible/offline-bible-reader.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
// import { useTranslations } from 'next-intl'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
CloudOff,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
NavigateBefore,
|
||||||
|
NavigateNext,
|
||||||
|
MenuBook,
|
||||||
|
Download
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
offlineStorage,
|
||||||
|
type BibleVersion,
|
||||||
|
type BibleBook,
|
||||||
|
type BibleChapter
|
||||||
|
} from '@/lib/offline-storage'
|
||||||
|
|
||||||
|
interface OfflineBibleReaderProps {
|
||||||
|
onRequestDownload?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineBibleReader({ onRequestDownload }: OfflineBibleReaderProps) {
|
||||||
|
// const t = useTranslations('bible')
|
||||||
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [versions, setVersions] = useState<BibleVersion[]>([])
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState<string>('')
|
||||||
|
const [books, setBooks] = useState<BibleBook[]>([])
|
||||||
|
const [selectedBook, setSelectedBook] = useState<string>('')
|
||||||
|
const [currentChapter, setCurrentChapter] = useState<BibleChapter | null>(null)
|
||||||
|
const [selectedChapterNum, setSelectedChapterNum] = useState<number>(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkOnlineStatus()
|
||||||
|
loadOfflineVersions()
|
||||||
|
|
||||||
|
// Listen for online/offline events
|
||||||
|
const handleOnline = () => setIsOnline(true)
|
||||||
|
const handleOffline = () => setIsOnline(false)
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedVersion) {
|
||||||
|
loadBooksForVersion(selectedVersion)
|
||||||
|
}
|
||||||
|
}, [selectedVersion])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBook && selectedVersion) {
|
||||||
|
loadChapter(selectedVersion, selectedBook, selectedChapterNum)
|
||||||
|
}
|
||||||
|
}, [selectedBook, selectedChapterNum, selectedVersion])
|
||||||
|
|
||||||
|
const checkOnlineStatus = () => {
|
||||||
|
setIsOnline(navigator.onLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOfflineVersions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const offlineVersions = await offlineStorage.getAllVersions()
|
||||||
|
setVersions(offlineVersions)
|
||||||
|
|
||||||
|
if (offlineVersions.length > 0 && !selectedVersion) {
|
||||||
|
setSelectedVersion(offlineVersions[0].id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load offline versions:', error)
|
||||||
|
setError('Failed to load offline Bible versions')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBooksForVersion = async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const versionBooks = await offlineStorage.getBooksForVersion(versionId)
|
||||||
|
setBooks(versionBooks.sort((a, b) => a.orderNum - b.orderNum))
|
||||||
|
|
||||||
|
if (versionBooks.length > 0 && !selectedBook) {
|
||||||
|
setSelectedBook(versionBooks[0].id)
|
||||||
|
setSelectedChapterNum(1)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load books:', error)
|
||||||
|
setError('Failed to load books for this version')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChapter = async (versionId: string, bookId: string, chapterNum: number) => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
const chapter = await offlineStorage.getChapter(versionId, bookId, chapterNum)
|
||||||
|
|
||||||
|
if (chapter) {
|
||||||
|
setCurrentChapter(chapter)
|
||||||
|
} else {
|
||||||
|
setError(`Chapter ${chapterNum} not found in offline storage`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chapter:', error)
|
||||||
|
setError('Failed to load chapter from offline storage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateChapter = (direction: 'prev' | 'next') => {
|
||||||
|
if (!currentChapter || !selectedBook) return
|
||||||
|
|
||||||
|
const currentBook = books.find(b => b.id === selectedBook)
|
||||||
|
if (!currentBook) return
|
||||||
|
|
||||||
|
let newChapterNum = selectedChapterNum
|
||||||
|
let newBookId = selectedBook
|
||||||
|
|
||||||
|
if (direction === 'next') {
|
||||||
|
if (selectedChapterNum < currentBook.chaptersCount) {
|
||||||
|
newChapterNum = selectedChapterNum + 1
|
||||||
|
} else {
|
||||||
|
// Move to next book
|
||||||
|
const currentBookIndex = books.findIndex(b => b.id === selectedBook)
|
||||||
|
if (currentBookIndex < books.length - 1) {
|
||||||
|
newBookId = books[currentBookIndex + 1].id
|
||||||
|
newChapterNum = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selectedChapterNum > 1) {
|
||||||
|
newChapterNum = selectedChapterNum - 1
|
||||||
|
} else {
|
||||||
|
// Move to previous book
|
||||||
|
const currentBookIndex = books.findIndex(b => b.id === selectedBook)
|
||||||
|
if (currentBookIndex > 0) {
|
||||||
|
const prevBook = books[currentBookIndex - 1]
|
||||||
|
newBookId = prevBook.id
|
||||||
|
newChapterNum = prevBook.chaptersCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newBookId !== selectedBook) {
|
||||||
|
setSelectedBook(newBookId)
|
||||||
|
}
|
||||||
|
setSelectedChapterNum(newChapterNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentBookName = () => {
|
||||||
|
return books.find(b => b.id === selectedBook)?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVersionName = () => {
|
||||||
|
return versions.find(v => v.id === selectedVersion)?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versions.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ m: 2 }}>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||||||
|
<CloudOff sx={{ fontSize: 60, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
No Offline Bible Versions
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" paragraph>
|
||||||
|
You haven't downloaded any Bible versions for offline reading yet.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Download />}
|
||||||
|
onClick={onRequestDownload}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Download Bible Versions
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
{/* Header with status */}
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<MenuBook color="primary" />
|
||||||
|
<Typography variant="h5">Offline Bible Reader</Typography>
|
||||||
|
<Chip
|
||||||
|
icon={isOnline ? <Wifi /> : <WifiOff />}
|
||||||
|
label={isOnline ? 'Online' : 'Offline'}
|
||||||
|
color={isOnline ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<Paper sx={{ p: 2, mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
|
<InputLabel>Bible Version</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedVersion}
|
||||||
|
label="Bible Version"
|
||||||
|
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||||
|
>
|
||||||
|
{versions.map((version) => (
|
||||||
|
<MenuItem key={version.id} value={version.id}>
|
||||||
|
{version.name} ({version.abbreviation})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
|
<InputLabel>Book</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedBook}
|
||||||
|
label="Book"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedBook(e.target.value)
|
||||||
|
setSelectedChapterNum(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{books.map((book) => (
|
||||||
|
<MenuItem key={book.id} value={book.id}>
|
||||||
|
{book.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||||
|
<InputLabel>Chapter</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedChapterNum}
|
||||||
|
label="Chapter"
|
||||||
|
onChange={(e) => setSelectedChapterNum(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{Array.from(
|
||||||
|
{ length: books.find(b => b.id === selectedBook)?.chaptersCount || 1 },
|
||||||
|
(_, i) => (
|
||||||
|
<MenuItem key={i + 1} value={i + 1}>
|
||||||
|
{i + 1}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chapter Content */}
|
||||||
|
{currentChapter && (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{/* Chapter Header */}
|
||||||
|
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
{getCurrentBookName()} {selectedChapterNum}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="text.secondary">
|
||||||
|
{getVersionName()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Verses */}
|
||||||
|
<Box sx={{ lineHeight: 2, fontSize: '1.1rem' }}>
|
||||||
|
{currentChapter.verses.map((verse) => (
|
||||||
|
<Box key={verse.id} sx={{ mb: 1 }}>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'primary.main',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
mr: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{verse.verseNum}
|
||||||
|
</Typography>
|
||||||
|
<Typography component="span">
|
||||||
|
{verse.text}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mt: 4,
|
||||||
|
pt: 2,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider'
|
||||||
|
}}>
|
||||||
|
<Tooltip title="Previous Chapter">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => navigateChapter('prev')}
|
||||||
|
disabled={selectedChapterNum === 1 && books.findIndex(b => b.id === selectedBook) === 0}
|
||||||
|
>
|
||||||
|
<NavigateBefore />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{currentChapter.verses.length} verses
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Tooltip title="Next Chapter">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => navigateChapter('next')}
|
||||||
|
disabled={
|
||||||
|
selectedChapterNum === (books.find(b => b.id === selectedBook)?.chaptersCount || 1) &&
|
||||||
|
books.findIndex(b => b.id === selectedBook) === books.length - 1
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NavigateNext />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
377
components/bible/offline-download-manager.tsx
Normal file
377
components/bible/offline-download-manager.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
// import { useTranslations } from 'next-intl'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
LinearProgress,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Chip,
|
||||||
|
Alert,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Delete,
|
||||||
|
CloudDownload,
|
||||||
|
Storage,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
CheckCircle,
|
||||||
|
Error,
|
||||||
|
Info
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import { bibleDownloadManager, type BibleVersion, type DownloadProgress } from '@/lib/offline-storage'
|
||||||
|
|
||||||
|
interface OfflineDownloadManagerProps {
|
||||||
|
availableVersions: BibleVersion[]
|
||||||
|
onVersionDownloaded?: (versionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineDownloadManager({ availableVersions, onVersionDownloaded }: OfflineDownloadManagerProps) {
|
||||||
|
// const t = useTranslations('bible')
|
||||||
|
const [downloadedVersions, setDownloadedVersions] = useState<BibleVersion[]>([])
|
||||||
|
const [downloads, setDownloads] = useState<Record<string, DownloadProgress>>({})
|
||||||
|
const [storageInfo, setStorageInfo] = useState({ used: 0, quota: 0, percentage: 0 })
|
||||||
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDownloadedVersions()
|
||||||
|
loadStorageInfo()
|
||||||
|
checkOnlineStatus()
|
||||||
|
|
||||||
|
// Listen for online/offline events
|
||||||
|
const handleOnline = () => setIsOnline(true)
|
||||||
|
const handleOffline = () => setIsOnline(false)
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadDownloadedVersions = async () => {
|
||||||
|
try {
|
||||||
|
const versions = await bibleDownloadManager.getDownloadedVersions()
|
||||||
|
setDownloadedVersions(versions)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Failed to load downloaded versions:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStorageInfo = async () => {
|
||||||
|
try {
|
||||||
|
const info = await bibleDownloadManager.getStorageInfo()
|
||||||
|
setStorageInfo(info)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Failed to load storage info:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkOnlineStatus = () => {
|
||||||
|
setIsOnline(navigator.onLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = async (version: BibleVersion) => {
|
||||||
|
if (!isOnline) {
|
||||||
|
alert('You need an internet connection to download Bible versions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bibleDownloadManager.downloadVersion(
|
||||||
|
version.id,
|
||||||
|
(progress: DownloadProgress) => {
|
||||||
|
setDownloads(prev => ({
|
||||||
|
...prev,
|
||||||
|
[version.id]: progress
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (progress.status === 'completed') {
|
||||||
|
loadDownloadedVersions()
|
||||||
|
loadStorageInfo()
|
||||||
|
onVersionDownloaded?.(version.id)
|
||||||
|
|
||||||
|
// Remove from downloads state after completion
|
||||||
|
setTimeout(() => {
|
||||||
|
setDownloads(prev => {
|
||||||
|
const { [version.id]: _, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Download failed:', error)
|
||||||
|
const errorMessage = 'Download failed'
|
||||||
|
alert(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
await bibleDownloadManager.deleteVersion(versionId)
|
||||||
|
setDownloadedVersions(prev => prev.filter(v => v.id !== versionId))
|
||||||
|
loadStorageInfo()
|
||||||
|
setConfirmDelete(null)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Delete failed:', error)
|
||||||
|
alert('Failed to delete version')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle color="success" />
|
||||||
|
case 'failed':
|
||||||
|
return <Error color="error" />
|
||||||
|
case 'downloading':
|
||||||
|
return <CloudDownload color="primary" />
|
||||||
|
default:
|
||||||
|
return <Info color="info" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVersionDownloaded = (versionId: string) => {
|
||||||
|
return downloadedVersions.some(v => v.id === versionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVersionDownloading = (versionId: string) => {
|
||||||
|
return downloads[versionId]?.status === 'downloading'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography>Loading offline storage...</Typography>
|
||||||
|
<LinearProgress sx={{ mt: 2 }} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Storage color="primary" />
|
||||||
|
<Typography variant="h5">Offline Bible Storage</Typography>
|
||||||
|
<Chip
|
||||||
|
icon={isOnline ? <Wifi /> : <WifiOff />}
|
||||||
|
label={isOnline ? 'Online' : 'Offline'}
|
||||||
|
color={isOnline ? 'success' : 'error'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Storage Info */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Storage Usage
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={storageInfo.percentage}
|
||||||
|
sx={{ height: 8, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{storageInfo.percentage.toFixed(1)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{formatBytes(storageInfo.used)} of {formatBytes(storageInfo.quota)} used
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{downloadedVersions.length} Bible versions downloaded
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Available Versions for Download */}
|
||||||
|
{isOnline && (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Available for Download
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
{availableVersions
|
||||||
|
.filter(version => !isVersionDownloaded(version.id))
|
||||||
|
.map((version) => (
|
||||||
|
<ListItem key={version.id}>
|
||||||
|
<ListItemText
|
||||||
|
primary={version.name}
|
||||||
|
secondary={`${version.abbreviation} - ${version.language}`}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Button
|
||||||
|
startIcon={<Download />}
|
||||||
|
onClick={() => handleDownload(version)}
|
||||||
|
disabled={isVersionDownloading(version.id)}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
{availableVersions.filter(v => !isVersionDownloaded(v.id)).length === 0 && (
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
All available versions are already downloaded
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Downloads */}
|
||||||
|
{Object.keys(downloads).length > 0 && (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Downloads in Progress
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
{Object.entries(downloads).map(([versionId, progress]) => {
|
||||||
|
const version = availableVersions.find(v => v.id === versionId)
|
||||||
|
return (
|
||||||
|
<ListItem key={versionId}>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{getStatusIcon(progress.status)}
|
||||||
|
<Typography>
|
||||||
|
{version?.name || versionId}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={progress.progress}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{progress.downloadedChapters} / {progress.totalChapters} chapters
|
||||||
|
({progress.progress}%)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Downloaded Versions */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Downloaded Versions
|
||||||
|
</Typography>
|
||||||
|
{downloadedVersions.length === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
No Bible versions downloaded yet. Download versions above to read offline.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<List dense>
|
||||||
|
{downloadedVersions.map((version) => (
|
||||||
|
<ListItem key={version.id}>
|
||||||
|
<ListItemText
|
||||||
|
primary={version.name}
|
||||||
|
secondary={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{version.abbreviation} - {version.language}
|
||||||
|
</Typography>
|
||||||
|
{version.downloadedAt && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Downloaded: {new Date(version.downloadedAt).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Tooltip title="Delete from offline storage">
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={() => setConfirmDelete(version.id)}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onClose={() => setConfirmDelete(null)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Delete Bible Version</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you want to delete this Bible version from offline storage?
|
||||||
|
You'll need to download it again to read offline.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfirmDelete(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => confirmDelete && handleDelete(confirmDelete)}
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
331
components/pwa/install-prompt.tsx
Normal file
331
components/pwa/install-prompt.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
// import { useTranslations } from 'next-intl'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Slide,
|
||||||
|
Paper,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
Close,
|
||||||
|
GetApp,
|
||||||
|
Smartphone,
|
||||||
|
CloudOff,
|
||||||
|
Speed,
|
||||||
|
Notifications,
|
||||||
|
Home,
|
||||||
|
Info
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import { usePWA } from '@/components/pwa/service-worker-provider'
|
||||||
|
|
||||||
|
interface InstallPromptProps {
|
||||||
|
autoShow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallPrompt({ autoShow = false }: InstallPromptProps) {
|
||||||
|
// const t = useTranslations('pwa')
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false)
|
||||||
|
const [showInfo, setShowInfo] = useState(false)
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false)
|
||||||
|
const { canInstall, installPrompt, isInstalled: pwaInstalled } = usePWA()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if app is already installed
|
||||||
|
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
setIsInstalled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto show prompt if configured and can install
|
||||||
|
if (autoShow && canInstall && !isInstalled && !hasUserDismissed()) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowPrompt(true)
|
||||||
|
}, 3000) // Show after 3 seconds
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [autoShow, canInstall, isInstalled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsInstalled(pwaInstalled)
|
||||||
|
}, [pwaInstalled])
|
||||||
|
|
||||||
|
const hasUserDismissed = () => {
|
||||||
|
return localStorage.getItem('pwa-install-dismissed') === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAsDismissed = () => {
|
||||||
|
localStorage.setItem('pwa-install-dismissed', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
try {
|
||||||
|
await installPrompt()
|
||||||
|
setShowPrompt(false)
|
||||||
|
setIsInstalled(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Installation failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setShowPrompt(false)
|
||||||
|
markAsDismissed()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowInfo = () => {
|
||||||
|
setShowInfo(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlatformInstructions = () => {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase()
|
||||||
|
|
||||||
|
if (userAgent.includes('iphone') || userAgent.includes('ipad')) {
|
||||||
|
return {
|
||||||
|
platform: 'iOS (Safari)',
|
||||||
|
steps: [
|
||||||
|
'Tap the Share button in Safari',
|
||||||
|
'Scroll down and tap "Add to Home Screen"',
|
||||||
|
'Tap "Add" to install the app'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} else if (userAgent.includes('android')) {
|
||||||
|
return {
|
||||||
|
platform: 'Android (Chrome)',
|
||||||
|
steps: [
|
||||||
|
'Tap the menu (three dots) in Chrome',
|
||||||
|
'Select "Add to Home screen"',
|
||||||
|
'Tap "Add" to install the app'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
platform: 'Desktop',
|
||||||
|
steps: [
|
||||||
|
'Look for the install icon in your browser\'s address bar',
|
||||||
|
'Click the install button or use the browser menu',
|
||||||
|
'Follow the prompts to install the app'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
return null // Don't show if already installed
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Install Prompt Card */}
|
||||||
|
<Slide direction="up" in={showPrompt} mountOnEnter unmountOnExit>
|
||||||
|
<Paper
|
||||||
|
elevation={8}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 1300,
|
||||||
|
maxWidth: 400,
|
||||||
|
mx: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
|
<GetApp color="primary" sx={{ mt: 0.5 }} />
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Install Biblical Guide
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Install our app for offline Bible reading, faster access, and a native app experience.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 2 }}>
|
||||||
|
{canInstall ? (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleInstall}
|
||||||
|
startIcon={<GetApp />}
|
||||||
|
>
|
||||||
|
Install App
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleShowInfo}
|
||||||
|
startIcon={<Info />}
|
||||||
|
>
|
||||||
|
How to Install
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
>
|
||||||
|
Not Now
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
sx={{ mt: -1, mr: -1 }}
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Paper>
|
||||||
|
</Slide>
|
||||||
|
|
||||||
|
{/* Installation Info Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={showInfo}
|
||||||
|
onClose={() => setShowInfo(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Smartphone color="primary" />
|
||||||
|
Install Biblical Guide App
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" paragraph>
|
||||||
|
Install our Progressive Web App for the best experience:
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Benefits */}
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||||
|
Benefits
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
<CloudOff color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Offline Reading"
|
||||||
|
secondary="Download Bible versions and read without internet"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Speed color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Faster Performance"
|
||||||
|
secondary="Native app-like speed and responsiveness"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Home color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Home Screen Access"
|
||||||
|
secondary="Quick access from your device's home screen"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Notifications color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Push Notifications"
|
||||||
|
secondary="Daily verses and prayer reminders (optional)"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{/* Installation Instructions */}
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||||
|
How to Install ({getPlatformInstructions().platform})
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
{getPlatformInstructions().steps.map((step, index) => (
|
||||||
|
<ListItem key={index}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Typography variant="body2" color="primary" fontWeight="bold">
|
||||||
|
{index + 1}
|
||||||
|
</Typography>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={step} />
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setShowInfo(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{canInstall && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleInstall}
|
||||||
|
startIcon={<GetApp />}
|
||||||
|
>
|
||||||
|
Install Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a hook for manual installation prompt
|
||||||
|
export function useInstallPrompt() {
|
||||||
|
const { canInstall, installPrompt } = usePWA()
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
setIsInstalled(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showInstallPrompt = async () => {
|
||||||
|
if (canInstall && !isInstalled) {
|
||||||
|
try {
|
||||||
|
await installPrompt()
|
||||||
|
setIsInstalled(true)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Installation failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canInstall: canInstall && !isInstalled,
|
||||||
|
isInstalled,
|
||||||
|
showInstallPrompt
|
||||||
|
}
|
||||||
|
}
|
||||||
238
components/pwa/service-worker-provider.tsx
Normal file
238
components/pwa/service-worker-provider.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
interface ServiceWorkerContextType {
|
||||||
|
isInstalling: boolean
|
||||||
|
isInstalled: boolean
|
||||||
|
isUpdateAvailable: boolean
|
||||||
|
updateServiceWorker: () => Promise<void>
|
||||||
|
installPrompt: () => Promise<void>
|
||||||
|
canInstall: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceWorkerProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isInstalling, setIsInstalling] = useState(false)
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false)
|
||||||
|
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false)
|
||||||
|
const [canInstall, setCanInstall] = useState(false)
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
|
||||||
|
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null)
|
||||||
|
const t = useTranslations('pwa')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
|
console.log('[PWA] Service Worker not supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registerServiceWorker()
|
||||||
|
setupInstallPrompt()
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (registration) {
|
||||||
|
registration.removeEventListener('updatefound', handleUpdateFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const registerServiceWorker = async () => {
|
||||||
|
try {
|
||||||
|
setIsInstalling(true)
|
||||||
|
console.log('[PWA] Registering service worker...')
|
||||||
|
|
||||||
|
const reg = await navigator.serviceWorker.register('/sw.js', {
|
||||||
|
scope: '/',
|
||||||
|
updateViaCache: 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
setRegistration(reg)
|
||||||
|
setIsInstalled(true)
|
||||||
|
console.log('[PWA] Service worker registered successfully')
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
reg.addEventListener('updatefound', handleUpdateFound)
|
||||||
|
|
||||||
|
// Check if there's already an update waiting
|
||||||
|
if (reg.waiting) {
|
||||||
|
setIsUpdateAvailable(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for controlling service worker changes
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
console.log('[PWA] Service worker controller changed')
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PWA] Service worker registration failed:', error)
|
||||||
|
} finally {
|
||||||
|
setIsInstalling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateFound = () => {
|
||||||
|
if (!registration) return
|
||||||
|
|
||||||
|
const newWorker = registration.installing
|
||||||
|
if (!newWorker) return
|
||||||
|
|
||||||
|
console.log('[PWA] New service worker installing...')
|
||||||
|
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
console.log('[PWA] New service worker installed, update available')
|
||||||
|
setIsUpdateAvailable(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateServiceWorker = async () => {
|
||||||
|
if (!registration || !registration.waiting) {
|
||||||
|
console.log('[PWA] No update available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[PWA] Updating service worker...')
|
||||||
|
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||||
|
setIsUpdateAvailable(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupInstallPrompt = () => {
|
||||||
|
// Listen for beforeinstallprompt event
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
console.log('[PWA] Install prompt available')
|
||||||
|
e.preventDefault()
|
||||||
|
setDeferredPrompt(e)
|
||||||
|
setCanInstall(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for app installed event
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
console.log('[PWA] App installed')
|
||||||
|
setCanInstall(false)
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
showNotification('App installed successfully!', 'success')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if already installed
|
||||||
|
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
console.log('[PWA] App is running in standalone mode')
|
||||||
|
setCanInstall(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installPrompt = async () => {
|
||||||
|
if (!deferredPrompt) {
|
||||||
|
console.log('[PWA] No install prompt available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[PWA] Showing install prompt...')
|
||||||
|
const result = await deferredPrompt.prompt()
|
||||||
|
console.log('[PWA] Install prompt result:', result.outcome)
|
||||||
|
|
||||||
|
if (result.outcome === 'accepted') {
|
||||||
|
setCanInstall(false)
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PWA] Install prompt failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||||
|
// Show a toast notification (you can integrate with your existing notification system)
|
||||||
|
console.log(`[PWA] ${type.toUpperCase()}: ${message}`)
|
||||||
|
|
||||||
|
// Create a simple toast
|
||||||
|
const toast = document.createElement('div')
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: ${type === 'success' ? '#4CAF50' : type === 'error' ? '#f44336' : '#2196F3'};
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 10000;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
`
|
||||||
|
toast.textContent = message
|
||||||
|
|
||||||
|
// Add animation keyframes if not already added
|
||||||
|
if (!document.querySelector('#pwa-toast-styles')) {
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = 'pwa-toast-styles'
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slideOut {
|
||||||
|
from { transform: translateX(0); opacity: 1; }
|
||||||
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(toast)
|
||||||
|
|
||||||
|
// Remove after 4 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.animation = 'slideOut 0.3s ease'
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(toast)
|
||||||
|
}, 300)
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide context value
|
||||||
|
const contextValue: ServiceWorkerContextType = {
|
||||||
|
isInstalling,
|
||||||
|
isInstalled,
|
||||||
|
isUpdateAvailable,
|
||||||
|
updateServiceWorker,
|
||||||
|
installPrompt,
|
||||||
|
canInstall
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, just provide the functions globally
|
||||||
|
// You can later create a proper React context if needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).pwa = contextValue
|
||||||
|
}
|
||||||
|
}, [contextValue])
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export hook for using PWA functionality
|
||||||
|
export function usePWA(): ServiceWorkerContextType {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
isInstalling: false,
|
||||||
|
isInstalled: false,
|
||||||
|
isUpdateAvailable: false,
|
||||||
|
updateServiceWorker: async () => {},
|
||||||
|
installPrompt: async () => {},
|
||||||
|
canInstall: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (window as any).pwa || {
|
||||||
|
isInstalling: false,
|
||||||
|
isInstalled: false,
|
||||||
|
isUpdateAvailable: false,
|
||||||
|
updateServiceWorker: async () => {},
|
||||||
|
installPrompt: async () => {},
|
||||||
|
canInstall: false
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
@@ -559,5 +559,15 @@
|
|||||||
"twitterTitle": "Biblical Guide – Online Bible Study with AI",
|
"twitterTitle": "Biblical Guide – Online Bible Study with AI",
|
||||||
"twitterDescription": "Online Bible study app with AI chat, daily verses, and prayer community.",
|
"twitterDescription": "Online Bible study app with AI chat, daily verses, and prayer community.",
|
||||||
"footer": "Biblical Guide – online Bible study app with AI chat, daily verses, and prayer community."
|
"footer": "Biblical Guide – online Bible study app with AI chat, daily verses, and prayer community."
|
||||||
|
},
|
||||||
|
"pwa": {
|
||||||
|
"install": "Install App",
|
||||||
|
"installing": "Installing...",
|
||||||
|
"installSuccess": "App installed successfully!",
|
||||||
|
"installFailed": "Installation failed",
|
||||||
|
"update": "Update available",
|
||||||
|
"updateReady": "Update ready",
|
||||||
|
"offline": "You're offline",
|
||||||
|
"onlineAgain": "You're back online!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,5 +559,15 @@
|
|||||||
"twitterTitle": "Biblical Guide – Studiu Biblic Online cu AI",
|
"twitterTitle": "Biblical Guide – Studiu Biblic Online cu AI",
|
||||||
"twitterDescription": "Aplicație biblică online cu chat AI, versete zilnice și comunitate de rugăciune.",
|
"twitterDescription": "Aplicație biblică online cu chat AI, versete zilnice și comunitate de rugăciune.",
|
||||||
"footer": "Biblical Guide – aplicație de studiu biblic online, cu chat AI, versete zilnice și comunitate de rugăciune."
|
"footer": "Biblical Guide – aplicație de studiu biblic online, cu chat AI, versete zilnice și comunitate de rugăciune."
|
||||||
|
},
|
||||||
|
"pwa": {
|
||||||
|
"install": "Instalează aplicația",
|
||||||
|
"installing": "Se instalează...",
|
||||||
|
"installSuccess": "Aplicația a fost instalată cu succes!",
|
||||||
|
"installFailed": "Instalarea a eșuat",
|
||||||
|
"update": "Actualizare disponibilă",
|
||||||
|
"updateReady": "Actualizare pregătită",
|
||||||
|
"offline": "Ești offline",
|
||||||
|
"onlineAgain": "Ești din nou online!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
91
public/manifest.json
Normal file
91
public/manifest.json
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"name": "Biblical Guide - Ghidul Biblic",
|
||||||
|
"short_name": "Biblical Guide",
|
||||||
|
"description": "Complete Bible study app with offline reading, AI chat, and multilingual support",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#009688",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"scope": "/",
|
||||||
|
"categories": ["books", "education", "lifestyle"],
|
||||||
|
"lang": "en",
|
||||||
|
"dir": "ltr",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/biblical-guide-og-image.png",
|
||||||
|
"sizes": "1200x630",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Read Bible",
|
||||||
|
"short_name": "Bible",
|
||||||
|
"description": "Start reading the Bible",
|
||||||
|
"url": "/bible",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Daily Verse",
|
||||||
|
"short_name": "Verse",
|
||||||
|
"description": "Read today's verse",
|
||||||
|
"url": "/?daily=true",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI Chat",
|
||||||
|
"short_name": "Chat",
|
||||||
|
"description": "Ask biblical questions",
|
||||||
|
"url": "/?chat=true",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Prayer Requests",
|
||||||
|
"short_name": "Prayers",
|
||||||
|
"description": "View prayer requests",
|
||||||
|
"url": "/prayers",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
"offline-reading",
|
||||||
|
"background-sync",
|
||||||
|
"push-notifications"
|
||||||
|
],
|
||||||
|
"related_applications": [],
|
||||||
|
"prefer_related_applications": false
|
||||||
|
}
|
||||||
152
public/offline.html
Normal file
152
public/offline.html
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - Biblical Guide</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #009688 0%, #00796B 100%);
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.offline-content {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.verse {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.reference {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">📖</div>
|
||||||
|
<h1>You're Offline</h1>
|
||||||
|
<p>No internet connection detected. You can still access your downloaded Bible versions and continue reading.</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="checkConnection()">Check Connection</button>
|
||||||
|
<button onclick="goToHomePage()">Continue Offline</button>
|
||||||
|
<button onclick="viewDownloads()">View Downloads</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="offline-content">
|
||||||
|
<div class="verse">
|
||||||
|
"For the word of God is alive and active. Sharper than any double-edged sword..."
|
||||||
|
</div>
|
||||||
|
<div class="reference">Hebrews 4:12</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status" id="status">
|
||||||
|
Offline mode - Limited functionality available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Check if we're online
|
||||||
|
function updateStatus() {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
if (navigator.onLine) {
|
||||||
|
status.textContent = 'Connection restored! You can refresh the page.';
|
||||||
|
status.style.color = '#4CAF50';
|
||||||
|
} else {
|
||||||
|
status.textContent = 'Offline mode - Limited functionality available';
|
||||||
|
status.style.color = 'rgba(255, 255, 255, 0.7)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkConnection() {
|
||||||
|
updateStatus();
|
||||||
|
if (navigator.onLine) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToHomePage() {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewDownloads() {
|
||||||
|
window.location.href = '/bible?offline=true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for online/offline events
|
||||||
|
window.addEventListener('online', updateStatus);
|
||||||
|
window.addEventListener('offline', updateStatus);
|
||||||
|
|
||||||
|
// Initial status check
|
||||||
|
updateStatus();
|
||||||
|
|
||||||
|
// Auto-refresh when connection is restored
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (navigator.onLine) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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