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 { useAuth } from '@/hooks/use-auth'
|
||||
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 {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -69,7 +72,10 @@ import {
|
||||
MenuBook,
|
||||
Visibility,
|
||||
Speed,
|
||||
Chat
|
||||
Chat,
|
||||
CloudDownload,
|
||||
WifiOff,
|
||||
Storage
|
||||
} from '@mui/icons-material'
|
||||
|
||||
interface BibleVerse {
|
||||
@@ -149,6 +155,11 @@ export default function BibleReaderNew() {
|
||||
const [showScrollTop, setShowScrollTop] = useState(false)
|
||||
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
|
||||
const [isChapterBookmarked, setIsChapterBookmarked] = useState(false)
|
||||
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
|
||||
@@ -177,6 +188,9 @@ export default function BibleReaderNew() {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const verseRefs = useRef<{[key: number]: HTMLDivElement}>({})
|
||||
|
||||
// PWA install prompt
|
||||
const { canInstall, isInstalled, showInstallPrompt } = useInstallPrompt()
|
||||
|
||||
// Load user preferences from localStorage
|
||||
useEffect(() => {
|
||||
const savedPrefs = localStorage.getItem('bibleReaderPreferences')
|
||||
@@ -235,6 +249,39 @@ export default function BibleReaderNew() {
|
||||
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
|
||||
useEffect(() => {
|
||||
setVersionsLoading(true)
|
||||
@@ -1093,6 +1140,24 @@ export default function BibleReaderNew() {
|
||||
<Share />
|
||||
</IconButton>
|
||||
</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>
|
||||
</Paper>
|
||||
)
|
||||
@@ -1386,6 +1451,38 @@ export default function BibleReaderNew() {
|
||||
{/* Settings Dialog */}
|
||||
{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 */}
|
||||
<Snackbar
|
||||
open={copyFeedback.open}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AuthProvider } from '@/components/auth/auth-provider'
|
||||
import { Navigation } from '@/components/layout/navigation'
|
||||
import { Footer } from '@/components/layout/footer'
|
||||
import FloatingChat from '@/components/chat/floating-chat'
|
||||
import { ServiceWorkerProvider } from '@/components/pwa/service-worker-provider'
|
||||
import { merriweather, lato } from '@/lib/fonts'
|
||||
|
||||
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'),
|
||||
images: [ogImageUrl],
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
other: {
|
||||
'application/ld+json': JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
@@ -119,12 +121,14 @@ export default async function LocaleLayout({
|
||||
<body className={`${merriweather.variable} ${lato.variable}`}>
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<MuiThemeProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<AuthProvider>
|
||||
<Navigation />
|
||||
{children}
|
||||
<Footer />
|
||||
<FloatingChat />
|
||||
</AuthProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</MuiThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</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",
|
||||
"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."
|
||||
},
|
||||
"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",
|
||||
"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."
|
||||
},
|
||||
"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