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