Implement comprehensive PWA with offline Bible reading capabilities

- Add Web App Manifest with app metadata, icons, and installation support
- Create Service Worker with intelligent caching strategies for Bible content, static assets, and dynamic content
- Implement IndexedDB-based offline storage system for Bible versions, books, chapters, and verses
- Add offline download manager component for browsing and downloading Bible versions
- Create offline Bible reader component for seamless offline reading experience
- Integrate PWA install prompt with platform-specific instructions
- Add offline reading interface to existing Bible reader with download buttons
- Create dedicated offline page with tabbed interface for reading and downloading
- Add PWA and offline-related translations for English and Romanian locales
- Implement background sync for Bible downloads and cache management
- Add storage usage monitoring and management utilities
- Ensure SSR-safe implementation with dynamic imports for client-side components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-28 22:20:44 +00:00
parent 83a981cabc
commit a01b2490dc
15 changed files with 2730 additions and 7 deletions

View File

@@ -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}

View File

@@ -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>
<AuthProvider>
<Navigation />
{children}
<Footer />
<FloatingChat />
</AuthProvider>
<ServiceWorkerProvider>
<AuthProvider>
<Navigation />
{children}
<Footer />
<FloatingChat />
</AuthProvider>
</ServiceWorkerProvider>
</MuiThemeProvider>
</NextIntlClientProvider>
</body>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}
}

View 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
View File

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

View File

@@ -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!"
}
}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

91
public/manifest.json Normal file
View 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
View 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
View 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');