- 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>
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
'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>
|
|
)
|
|
} |