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

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