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:
371
components/bible/offline-bible-reader.tsx
Normal file
371
components/bible/offline-bible-reader.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
// import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Paper,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material'
|
||||
import {
|
||||
CloudOff,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
NavigateBefore,
|
||||
NavigateNext,
|
||||
MenuBook,
|
||||
Download
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
offlineStorage,
|
||||
type BibleVersion,
|
||||
type BibleBook,
|
||||
type BibleChapter
|
||||
} from '@/lib/offline-storage'
|
||||
|
||||
interface OfflineBibleReaderProps {
|
||||
onRequestDownload?: () => void
|
||||
}
|
||||
|
||||
export function OfflineBibleReader({ onRequestDownload }: OfflineBibleReaderProps) {
|
||||
// const t = useTranslations('bible')
|
||||
const [isOnline, setIsOnline] = useState(true)
|
||||
const [versions, setVersions] = useState<BibleVersion[]>([])
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>('')
|
||||
const [books, setBooks] = useState<BibleBook[]>([])
|
||||
const [selectedBook, setSelectedBook] = useState<string>('')
|
||||
const [currentChapter, setCurrentChapter] = useState<BibleChapter | null>(null)
|
||||
const [selectedChapterNum, setSelectedChapterNum] = useState<number>(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkOnlineStatus()
|
||||
loadOfflineVersions()
|
||||
|
||||
// Listen for online/offline events
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVersion) {
|
||||
loadBooksForVersion(selectedVersion)
|
||||
}
|
||||
}, [selectedVersion])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBook && selectedVersion) {
|
||||
loadChapter(selectedVersion, selectedBook, selectedChapterNum)
|
||||
}
|
||||
}, [selectedBook, selectedChapterNum, selectedVersion])
|
||||
|
||||
const checkOnlineStatus = () => {
|
||||
setIsOnline(navigator.onLine)
|
||||
}
|
||||
|
||||
const loadOfflineVersions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const offlineVersions = await offlineStorage.getAllVersions()
|
||||
setVersions(offlineVersions)
|
||||
|
||||
if (offlineVersions.length > 0 && !selectedVersion) {
|
||||
setSelectedVersion(offlineVersions[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load offline versions:', error)
|
||||
setError('Failed to load offline Bible versions')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadBooksForVersion = async (versionId: string) => {
|
||||
try {
|
||||
const versionBooks = await offlineStorage.getBooksForVersion(versionId)
|
||||
setBooks(versionBooks.sort((a, b) => a.orderNum - b.orderNum))
|
||||
|
||||
if (versionBooks.length > 0 && !selectedBook) {
|
||||
setSelectedBook(versionBooks[0].id)
|
||||
setSelectedChapterNum(1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load books:', error)
|
||||
setError('Failed to load books for this version')
|
||||
}
|
||||
}
|
||||
|
||||
const loadChapter = async (versionId: string, bookId: string, chapterNum: number) => {
|
||||
try {
|
||||
setError(null)
|
||||
const chapter = await offlineStorage.getChapter(versionId, bookId, chapterNum)
|
||||
|
||||
if (chapter) {
|
||||
setCurrentChapter(chapter)
|
||||
} else {
|
||||
setError(`Chapter ${chapterNum} not found in offline storage`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load chapter:', error)
|
||||
setError('Failed to load chapter from offline storage')
|
||||
}
|
||||
}
|
||||
|
||||
const navigateChapter = (direction: 'prev' | 'next') => {
|
||||
if (!currentChapter || !selectedBook) return
|
||||
|
||||
const currentBook = books.find(b => b.id === selectedBook)
|
||||
if (!currentBook) return
|
||||
|
||||
let newChapterNum = selectedChapterNum
|
||||
let newBookId = selectedBook
|
||||
|
||||
if (direction === 'next') {
|
||||
if (selectedChapterNum < currentBook.chaptersCount) {
|
||||
newChapterNum = selectedChapterNum + 1
|
||||
} else {
|
||||
// Move to next book
|
||||
const currentBookIndex = books.findIndex(b => b.id === selectedBook)
|
||||
if (currentBookIndex < books.length - 1) {
|
||||
newBookId = books[currentBookIndex + 1].id
|
||||
newChapterNum = 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (selectedChapterNum > 1) {
|
||||
newChapterNum = selectedChapterNum - 1
|
||||
} else {
|
||||
// Move to previous book
|
||||
const currentBookIndex = books.findIndex(b => b.id === selectedBook)
|
||||
if (currentBookIndex > 0) {
|
||||
const prevBook = books[currentBookIndex - 1]
|
||||
newBookId = prevBook.id
|
||||
newChapterNum = prevBook.chaptersCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newBookId !== selectedBook) {
|
||||
setSelectedBook(newBookId)
|
||||
}
|
||||
setSelectedChapterNum(newChapterNum)
|
||||
}
|
||||
|
||||
const getCurrentBookName = () => {
|
||||
return books.find(b => b.id === selectedBook)?.name || ''
|
||||
}
|
||||
|
||||
const getVersionName = () => {
|
||||
return versions.find(v => v.id === selectedVersion)?.name || ''
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<Card sx={{ m: 2 }}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||||
<CloudOff sx={{ fontSize: 60, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h5" gutterBottom>
|
||||
No Offline Bible Versions
|
||||
</Typography>
|
||||
<Typography color="text.secondary" paragraph>
|
||||
You haven't downloaded any Bible versions for offline reading yet.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={onRequestDownload}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Download Bible Versions
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* Header with status */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<MenuBook color="primary" />
|
||||
<Typography variant="h5">Offline Bible Reader</Typography>
|
||||
<Chip
|
||||
icon={isOnline ? <Wifi /> : <WifiOff />}
|
||||
label={isOnline ? 'Online' : 'Offline'}
|
||||
color={isOnline ? 'success' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Bible Version</InputLabel>
|
||||
<Select
|
||||
value={selectedVersion}
|
||||
label="Bible Version"
|
||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||
>
|
||||
{versions.map((version) => (
|
||||
<MenuItem key={version.id} value={version.id}>
|
||||
{version.name} ({version.abbreviation})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Book</InputLabel>
|
||||
<Select
|
||||
value={selectedBook}
|
||||
label="Book"
|
||||
onChange={(e) => {
|
||||
setSelectedBook(e.target.value)
|
||||
setSelectedChapterNum(1)
|
||||
}}
|
||||
>
|
||||
{books.map((book) => (
|
||||
<MenuItem key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Chapter</InputLabel>
|
||||
<Select
|
||||
value={selectedChapterNum}
|
||||
label="Chapter"
|
||||
onChange={(e) => setSelectedChapterNum(Number(e.target.value))}
|
||||
>
|
||||
{Array.from(
|
||||
{ length: books.find(b => b.id === selectedBook)?.chaptersCount || 1 },
|
||||
(_, i) => (
|
||||
<MenuItem key={i + 1} value={i + 1}>
|
||||
{i + 1}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Chapter Content */}
|
||||
{currentChapter && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
{/* Chapter Header */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{getCurrentBookName()} {selectedChapterNum}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{getVersionName()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Verses */}
|
||||
<Box sx={{ lineHeight: 2, fontSize: '1.1rem' }}>
|
||||
{currentChapter.verses.map((verse) => (
|
||||
<Box key={verse.id} sx={{ mb: 1 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: 'primary.main',
|
||||
fontSize: '0.9em',
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{verse.verseNum}
|
||||
</Typography>
|
||||
<Typography component="span">
|
||||
{verse.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Navigation */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mt: 4,
|
||||
pt: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<Tooltip title="Previous Chapter">
|
||||
<IconButton
|
||||
onClick={() => navigateChapter('prev')}
|
||||
disabled={selectedChapterNum === 1 && books.findIndex(b => b.id === selectedBook) === 0}
|
||||
>
|
||||
<NavigateBefore />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{currentChapter.verses.length} verses
|
||||
</Typography>
|
||||
|
||||
<Tooltip title="Next Chapter">
|
||||
<IconButton
|
||||
onClick={() => navigateChapter('next')}
|
||||
disabled={
|
||||
selectedChapterNum === (books.find(b => b.id === selectedBook)?.chaptersCount || 1) &&
|
||||
books.findIndex(b => b.id === selectedBook) === books.length - 1
|
||||
}
|
||||
>
|
||||
<NavigateNext />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
377
components/bible/offline-download-manager.tsx
Normal file
377
components/bible/offline-download-manager.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
// import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Alert,
|
||||
Tooltip
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Download,
|
||||
Delete,
|
||||
CloudDownload,
|
||||
Storage,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Info
|
||||
} from '@mui/icons-material'
|
||||
import { bibleDownloadManager, type BibleVersion, type DownloadProgress } from '@/lib/offline-storage'
|
||||
|
||||
interface OfflineDownloadManagerProps {
|
||||
availableVersions: BibleVersion[]
|
||||
onVersionDownloaded?: (versionId: string) => void
|
||||
}
|
||||
|
||||
export function OfflineDownloadManager({ availableVersions, onVersionDownloaded }: OfflineDownloadManagerProps) {
|
||||
// const t = useTranslations('bible')
|
||||
const [downloadedVersions, setDownloadedVersions] = useState<BibleVersion[]>([])
|
||||
const [downloads, setDownloads] = useState<Record<string, DownloadProgress>>({})
|
||||
const [storageInfo, setStorageInfo] = useState({ used: 0, quota: 0, percentage: 0 })
|
||||
const [isOnline, setIsOnline] = useState(true)
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadDownloadedVersions()
|
||||
loadStorageInfo()
|
||||
checkOnlineStatus()
|
||||
|
||||
// Listen for online/offline events
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadDownloadedVersions = async () => {
|
||||
try {
|
||||
const versions = await bibleDownloadManager.getDownloadedVersions()
|
||||
setDownloadedVersions(versions)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load downloaded versions:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadStorageInfo = async () => {
|
||||
try {
|
||||
const info = await bibleDownloadManager.getStorageInfo()
|
||||
setStorageInfo(info)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load storage info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const checkOnlineStatus = () => {
|
||||
setIsOnline(navigator.onLine)
|
||||
}
|
||||
|
||||
const handleDownload = async (version: BibleVersion) => {
|
||||
if (!isOnline) {
|
||||
alert('You need an internet connection to download Bible versions')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await bibleDownloadManager.downloadVersion(
|
||||
version.id,
|
||||
(progress: DownloadProgress) => {
|
||||
setDownloads(prev => ({
|
||||
...prev,
|
||||
[version.id]: progress
|
||||
}))
|
||||
|
||||
if (progress.status === 'completed') {
|
||||
loadDownloadedVersions()
|
||||
loadStorageInfo()
|
||||
onVersionDownloaded?.(version.id)
|
||||
|
||||
// Remove from downloads state after completion
|
||||
setTimeout(() => {
|
||||
setDownloads(prev => {
|
||||
const { [version.id]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
console.error('Download failed:', error)
|
||||
const errorMessage = 'Download failed'
|
||||
alert(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (versionId: string) => {
|
||||
try {
|
||||
await bibleDownloadManager.deleteVersion(versionId)
|
||||
setDownloadedVersions(prev => prev.filter(v => v.id !== versionId))
|
||||
loadStorageInfo()
|
||||
setConfirmDelete(null)
|
||||
} catch (error: unknown) {
|
||||
console.error('Delete failed:', error)
|
||||
alert('Failed to delete version')
|
||||
}
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle color="success" />
|
||||
case 'failed':
|
||||
return <Error color="error" />
|
||||
case 'downloading':
|
||||
return <CloudDownload color="primary" />
|
||||
default:
|
||||
return <Info color="info" />
|
||||
}
|
||||
}
|
||||
|
||||
const isVersionDownloaded = (versionId: string) => {
|
||||
return downloadedVersions.some(v => v.id === versionId)
|
||||
}
|
||||
|
||||
const isVersionDownloading = (versionId: string) => {
|
||||
return downloads[versionId]?.status === 'downloading'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography>Loading offline storage...</Typography>
|
||||
<LinearProgress sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Storage color="primary" />
|
||||
<Typography variant="h5">Offline Bible Storage</Typography>
|
||||
<Chip
|
||||
icon={isOnline ? <Wifi /> : <WifiOff />}
|
||||
label={isOnline ? 'Online' : 'Offline'}
|
||||
color={isOnline ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Storage Info */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Storage Usage
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={storageInfo.percentage}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{storageInfo.percentage.toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatBytes(storageInfo.used)} of {formatBytes(storageInfo.quota)} used
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{downloadedVersions.length} Bible versions downloaded
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available Versions for Download */}
|
||||
{isOnline && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Available for Download
|
||||
</Typography>
|
||||
<List dense>
|
||||
{availableVersions
|
||||
.filter(version => !isVersionDownloaded(version.id))
|
||||
.map((version) => (
|
||||
<ListItem key={version.id}>
|
||||
<ListItemText
|
||||
primary={version.name}
|
||||
secondary={`${version.abbreviation} - ${version.language}`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
onClick={() => handleDownload(version)}
|
||||
disabled={isVersionDownloading(version.id)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{availableVersions.filter(v => !isVersionDownloaded(v.id)).length === 0 && (
|
||||
<Typography color="text.secondary">
|
||||
All available versions are already downloaded
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Downloads */}
|
||||
{Object.keys(downloads).length > 0 && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Downloads in Progress
|
||||
</Typography>
|
||||
<List dense>
|
||||
{Object.entries(downloads).map(([versionId, progress]) => {
|
||||
const version = availableVersions.find(v => v.id === versionId)
|
||||
return (
|
||||
<ListItem key={versionId}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(progress.status)}
|
||||
<Typography>
|
||||
{version?.name || versionId}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.progress}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{progress.downloadedChapters} / {progress.totalChapters} chapters
|
||||
({progress.progress}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Downloaded Versions */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Downloaded Versions
|
||||
</Typography>
|
||||
{downloadedVersions.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No Bible versions downloaded yet. Download versions above to read offline.
|
||||
</Alert>
|
||||
) : (
|
||||
<List dense>
|
||||
{downloadedVersions.map((version) => (
|
||||
<ListItem key={version.id}>
|
||||
<ListItemText
|
||||
primary={version.name}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{version.abbreviation} - {version.language}
|
||||
</Typography>
|
||||
{version.downloadedAt && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Downloaded: {new Date(version.downloadedAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Delete from offline storage">
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => setConfirmDelete(version.id)}
|
||||
color="error"
|
||||
size="small"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={!!confirmDelete}
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
>
|
||||
<DialogTitle>Delete Bible Version</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete this Bible version from offline storage?
|
||||
You'll need to download it again to read offline.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDelete(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => confirmDelete && handleDelete(confirmDelete)}
|
||||
color="error"
|
||||
variant="contained"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
331
components/pwa/install-prompt.tsx
Normal file
331
components/pwa/install-prompt.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
// import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Slide,
|
||||
Paper,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Close,
|
||||
GetApp,
|
||||
Smartphone,
|
||||
CloudOff,
|
||||
Speed,
|
||||
Notifications,
|
||||
Home,
|
||||
Info
|
||||
} from '@mui/icons-material'
|
||||
import { usePWA } from '@/components/pwa/service-worker-provider'
|
||||
|
||||
interface InstallPromptProps {
|
||||
autoShow?: boolean
|
||||
}
|
||||
|
||||
export function InstallPrompt({ autoShow = false }: InstallPromptProps) {
|
||||
// const t = useTranslations('pwa')
|
||||
const [showPrompt, setShowPrompt] = useState(false)
|
||||
const [showInfo, setShowInfo] = useState(false)
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
const { canInstall, installPrompt, isInstalled: pwaInstalled } = usePWA()
|
||||
|
||||
useEffect(() => {
|
||||
// Check if app is already installed
|
||||
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
||||
setIsInstalled(true)
|
||||
}
|
||||
|
||||
// Auto show prompt if configured and can install
|
||||
if (autoShow && canInstall && !isInstalled && !hasUserDismissed()) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowPrompt(true)
|
||||
}, 3000) // Show after 3 seconds
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [autoShow, canInstall, isInstalled])
|
||||
|
||||
useEffect(() => {
|
||||
setIsInstalled(pwaInstalled)
|
||||
}, [pwaInstalled])
|
||||
|
||||
const hasUserDismissed = () => {
|
||||
return localStorage.getItem('pwa-install-dismissed') === 'true'
|
||||
}
|
||||
|
||||
const markAsDismissed = () => {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true')
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
await installPrompt()
|
||||
setShowPrompt(false)
|
||||
setIsInstalled(true)
|
||||
} catch (error) {
|
||||
console.error('Installation failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false)
|
||||
markAsDismissed()
|
||||
}
|
||||
|
||||
const handleShowInfo = () => {
|
||||
setShowInfo(true)
|
||||
}
|
||||
|
||||
const getPlatformInstructions = () => {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
|
||||
if (userAgent.includes('iphone') || userAgent.includes('ipad')) {
|
||||
return {
|
||||
platform: 'iOS (Safari)',
|
||||
steps: [
|
||||
'Tap the Share button in Safari',
|
||||
'Scroll down and tap "Add to Home Screen"',
|
||||
'Tap "Add" to install the app'
|
||||
]
|
||||
}
|
||||
} else if (userAgent.includes('android')) {
|
||||
return {
|
||||
platform: 'Android (Chrome)',
|
||||
steps: [
|
||||
'Tap the menu (three dots) in Chrome',
|
||||
'Select "Add to Home screen"',
|
||||
'Tap "Add" to install the app'
|
||||
]
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
platform: 'Desktop',
|
||||
steps: [
|
||||
'Look for the install icon in your browser\'s address bar',
|
||||
'Click the install button or use the browser menu',
|
||||
'Follow the prompts to install the app'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return null // Don't show if already installed
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Install Prompt Card */}
|
||||
<Slide direction="up" in={showPrompt} mountOnEnter unmountOnExit>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
zIndex: 1300,
|
||||
maxWidth: 400,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<GetApp color="primary" sx={{ mt: 0.5 }} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Install Biblical Guide
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Install our app for offline Bible reading, faster access, and a native app experience.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 2 }}>
|
||||
{canInstall ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleInstall}
|
||||
startIcon={<GetApp />}
|
||||
>
|
||||
Install App
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleShowInfo}
|
||||
startIcon={<Info />}
|
||||
>
|
||||
How to Install
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
Not Now
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleDismiss}
|
||||
sx={{ mt: -1, mr: -1 }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Slide>
|
||||
|
||||
{/* Installation Info Dialog */}
|
||||
<Dialog
|
||||
open={showInfo}
|
||||
onClose={() => setShowInfo(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Smartphone color="primary" />
|
||||
Install Biblical Guide App
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Typography variant="body1" paragraph>
|
||||
Install our Progressive Web App for the best experience:
|
||||
</Typography>
|
||||
|
||||
{/* Benefits */}
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Benefits
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CloudOff color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Offline Reading"
|
||||
secondary="Download Bible versions and read without internet"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Speed color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Faster Performance"
|
||||
secondary="Native app-like speed and responsiveness"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Home color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Home Screen Access"
|
||||
secondary="Quick access from your device's home screen"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Notifications color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Push Notifications"
|
||||
secondary="Daily verses and prayer reminders (optional)"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{/* Installation Instructions */}
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
How to Install ({getPlatformInstructions().platform})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{getPlatformInstructions().steps.map((step, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<Typography variant="body2" color="primary" fontWeight="bold">
|
||||
{index + 1}
|
||||
</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={step} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowInfo(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{canInstall && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleInstall}
|
||||
startIcon={<GetApp />}
|
||||
>
|
||||
Install Now
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Export a hook for manual installation prompt
|
||||
export function useInstallPrompt() {
|
||||
const { canInstall, installPrompt } = usePWA()
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
||||
setIsInstalled(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showInstallPrompt = async () => {
|
||||
if (canInstall && !isInstalled) {
|
||||
try {
|
||||
await installPrompt()
|
||||
setIsInstalled(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Installation failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
canInstall: canInstall && !isInstalled,
|
||||
isInstalled,
|
||||
showInstallPrompt
|
||||
}
|
||||
}
|
||||
238
components/pwa/service-worker-provider.tsx
Normal file
238
components/pwa/service-worker-provider.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface ServiceWorkerContextType {
|
||||
isInstalling: boolean
|
||||
isInstalled: boolean
|
||||
isUpdateAvailable: boolean
|
||||
updateServiceWorker: () => Promise<void>
|
||||
installPrompt: () => Promise<void>
|
||||
canInstall: boolean
|
||||
}
|
||||
|
||||
export function ServiceWorkerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false)
|
||||
const [canInstall, setCanInstall] = useState(false)
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
|
||||
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null)
|
||||
const t = useTranslations('pwa')
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
console.log('[PWA] Service Worker not supported')
|
||||
return
|
||||
}
|
||||
|
||||
registerServiceWorker()
|
||||
setupInstallPrompt()
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (registration) {
|
||||
registration.removeEventListener('updatefound', handleUpdateFound)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const registerServiceWorker = async () => {
|
||||
try {
|
||||
setIsInstalling(true)
|
||||
console.log('[PWA] Registering service worker...')
|
||||
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
updateViaCache: 'none'
|
||||
})
|
||||
|
||||
setRegistration(reg)
|
||||
setIsInstalled(true)
|
||||
console.log('[PWA] Service worker registered successfully')
|
||||
|
||||
// Check for updates
|
||||
reg.addEventListener('updatefound', handleUpdateFound)
|
||||
|
||||
// Check if there's already an update waiting
|
||||
if (reg.waiting) {
|
||||
setIsUpdateAvailable(true)
|
||||
}
|
||||
|
||||
// Listen for controlling service worker changes
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[PWA] Service worker controller changed')
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PWA] Service worker registration failed:', error)
|
||||
} finally {
|
||||
setIsInstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateFound = () => {
|
||||
if (!registration) return
|
||||
|
||||
const newWorker = registration.installing
|
||||
if (!newWorker) return
|
||||
|
||||
console.log('[PWA] New service worker installing...')
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
console.log('[PWA] New service worker installed, update available')
|
||||
setIsUpdateAvailable(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateServiceWorker = async () => {
|
||||
if (!registration || !registration.waiting) {
|
||||
console.log('[PWA] No update available')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[PWA] Updating service worker...')
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
setIsUpdateAvailable(false)
|
||||
}
|
||||
|
||||
const setupInstallPrompt = () => {
|
||||
// Listen for beforeinstallprompt event
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
console.log('[PWA] Install prompt available')
|
||||
e.preventDefault()
|
||||
setDeferredPrompt(e)
|
||||
setCanInstall(true)
|
||||
})
|
||||
|
||||
// Listen for app installed event
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('[PWA] App installed')
|
||||
setCanInstall(false)
|
||||
setDeferredPrompt(null)
|
||||
showNotification('App installed successfully!', 'success')
|
||||
})
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) {
|
||||
console.log('[PWA] App is running in standalone mode')
|
||||
setCanInstall(false)
|
||||
}
|
||||
}
|
||||
|
||||
const installPrompt = async () => {
|
||||
if (!deferredPrompt) {
|
||||
console.log('[PWA] No install prompt available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[PWA] Showing install prompt...')
|
||||
const result = await deferredPrompt.prompt()
|
||||
console.log('[PWA] Install prompt result:', result.outcome)
|
||||
|
||||
if (result.outcome === 'accepted') {
|
||||
setCanInstall(false)
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PWA] Install prompt failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
// Show a toast notification (you can integrate with your existing notification system)
|
||||
console.log(`[PWA] ${type.toUpperCase()}: ${message}`)
|
||||
|
||||
// Create a simple toast
|
||||
const toast = document.createElement('div')
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: ${type === 'success' ? '#4CAF50' : type === 'error' ? '#f44336' : '#2196F3'};
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
z-index: 10000;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
animation: slideIn 0.3s ease;
|
||||
`
|
||||
toast.textContent = message
|
||||
|
||||
// Add animation keyframes if not already added
|
||||
if (!document.querySelector('#pwa-toast-styles')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'pwa-toast-styles'
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
document.body.appendChild(toast)
|
||||
|
||||
// Remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease'
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast)
|
||||
}, 300)
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
// Provide context value
|
||||
const contextValue: ServiceWorkerContextType = {
|
||||
isInstalling,
|
||||
isInstalled,
|
||||
isUpdateAvailable,
|
||||
updateServiceWorker,
|
||||
installPrompt,
|
||||
canInstall
|
||||
}
|
||||
|
||||
// For now, just provide the functions globally
|
||||
// You can later create a proper React context if needed
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).pwa = contextValue
|
||||
}
|
||||
}, [contextValue])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Export hook for using PWA functionality
|
||||
export function usePWA(): ServiceWorkerContextType {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
isInstalling: false,
|
||||
isInstalled: false,
|
||||
isUpdateAvailable: false,
|
||||
updateServiceWorker: async () => {},
|
||||
installPrompt: async () => {},
|
||||
canInstall: false
|
||||
}
|
||||
}
|
||||
|
||||
return (window as any).pwa || {
|
||||
isInstalling: false,
|
||||
isInstalled: false,
|
||||
isUpdateAvailable: false,
|
||||
updateServiceWorker: async () => {},
|
||||
installPrompt: async () => {},
|
||||
canInstall: false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user