feat: improve Bible reader UX with dropdown menu and enhanced offline features
- Replace three separate verse action icons with compact three-dot dropdown menu - Bookmark, Copy Verse, and Ask AI now in a single menu - Better space utilization on mobile, tablet, and desktop - Enhance offline Bible downloads UI - Move downloaded versions list to top for better visibility - Add inline progress bars during downloads - Show real-time download progress with chapter counts - Add refresh button for downloaded versions list - Remove duplicate header, keep only main header with online/offline status - Improve build performance - Add .eslintignore to speed up linting phase - Already excludes large directories (bibles/, scripts/, csv_bibles/) - Add debug logging for offline storage operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
16
.eslintignore
Normal file
16
.eslintignore
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
bibles/
|
||||
scripts/
|
||||
csv_bibles/
|
||||
venv/
|
||||
*.csv
|
||||
.env
|
||||
.env.local
|
||||
prisma/
|
||||
public/
|
||||
temp/
|
||||
logs/
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -80,3 +80,10 @@ env/
|
||||
|
||||
# Import logs
|
||||
import_log.txt
|
||||
|
||||
# Large CSV files and conversion scripts
|
||||
*.csv
|
||||
csv_bibles/
|
||||
all_bibles_combined.csv
|
||||
create_combined_bible_csv.py
|
||||
json_to_csv_converter.py
|
||||
@@ -6,6 +6,7 @@ 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 { offlineStorage } from '@/lib/offline-storage'
|
||||
import { InstallPrompt, useInstallPrompt } from '@/components/pwa/install-prompt'
|
||||
import {
|
||||
Box,
|
||||
@@ -75,7 +76,8 @@ import {
|
||||
Chat,
|
||||
CloudDownload,
|
||||
WifiOff,
|
||||
Storage
|
||||
Storage,
|
||||
MoreVert
|
||||
} from '@mui/icons-material'
|
||||
|
||||
interface BibleVerse {
|
||||
@@ -218,6 +220,15 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
message: ''
|
||||
})
|
||||
|
||||
// Verse menu state
|
||||
const [verseMenuAnchor, setVerseMenuAnchor] = useState<{
|
||||
element: HTMLElement | null
|
||||
verse: BibleVerse | null
|
||||
}>({
|
||||
element: null,
|
||||
verse: null
|
||||
})
|
||||
|
||||
// Refs
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const verseRefs = useRef<{[key: number]: HTMLDivElement}>({})
|
||||
@@ -450,16 +461,48 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
// Create an AbortController for this request
|
||||
const abortController = new AbortController()
|
||||
|
||||
fetch(`/api/bible/books?locale=${locale}&version=${debouncedVersion}`, {
|
||||
signal: abortController.signal
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`)
|
||||
// First, try to load books from offline storage
|
||||
const loadBooksOfflineFirst = async () => {
|
||||
try {
|
||||
// Check if we have the books offline
|
||||
const offlineBooks = await offlineStorage.getBooksForVersion(debouncedVersion)
|
||||
|
||||
if (offlineBooks && offlineBooks.length > 0 && !abortController.signal.aborted) {
|
||||
console.log('Loading books from offline storage')
|
||||
// Transform offline books to match the expected interface
|
||||
const transformedBooks = offlineBooks.map(book => ({
|
||||
id: book.id,
|
||||
name: book.name,
|
||||
testament: book.testament,
|
||||
orderNum: book.orderNum,
|
||||
bookKey: book.abbreviation, // Map abbreviation to bookKey
|
||||
versionId: debouncedVersion,
|
||||
chaptersCount: book.chaptersCount,
|
||||
chapters: [] // Add empty chapters array to match interface
|
||||
}))
|
||||
setBooks(transformedBooks)
|
||||
if (!initialBook) {
|
||||
setSelectedBook(transformedBooks[0].id)
|
||||
}
|
||||
setLoading(false)
|
||||
return // Exit early, we have offline content
|
||||
}
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
} catch (offlineError) {
|
||||
console.log('Offline storage not available or books not found, falling back to API')
|
||||
}
|
||||
|
||||
// Fallback to API if offline content not available
|
||||
try {
|
||||
const response = await fetch(`/api/bible/books?locale=${locale}&version=${debouncedVersion}`, {
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Only update state if the request wasn't aborted
|
||||
if (!abortController.signal.aborted) {
|
||||
setBooks(data.books || [])
|
||||
@@ -468,14 +511,16 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
} catch (err) {
|
||||
// Only log error if it's not an abort error
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error('Error fetching books:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
loadBooksOfflineFirst()
|
||||
|
||||
// Cleanup function to abort the request if component unmounts or dependencies change
|
||||
return () => {
|
||||
@@ -533,16 +578,53 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
// Create an AbortController for this request
|
||||
const abortController = new AbortController()
|
||||
|
||||
fetch(`/api/bible/verses?bookId=${selectedBook}&chapter=${selectedChapter}`, {
|
||||
signal: abortController.signal
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`)
|
||||
// First, try to load from offline storage if available
|
||||
const loadChapterOfflineFirst = async () => {
|
||||
try {
|
||||
// Check if we have the chapter offline
|
||||
const offlineChapter = await offlineStorage.getChapter(selectedVersion, selectedBook, selectedChapter)
|
||||
|
||||
if (offlineChapter && !abortController.signal.aborted) {
|
||||
console.log('Loading chapter from offline storage')
|
||||
|
||||
// Store previous verses before updating
|
||||
setPreviousVerses(verses)
|
||||
|
||||
// Use requestAnimationFrame to ensure smooth transition
|
||||
requestAnimationFrame(() => {
|
||||
setVerses(offlineChapter.verses)
|
||||
|
||||
// Small delay to allow content to render before removing loading state
|
||||
setTimeout(() => {
|
||||
setLoading(false)
|
||||
setPreviousVerses([]) // Clear previous content after transition
|
||||
|
||||
// Restore scroll position if we're not navigating to a specific verse
|
||||
const urlVerse = new URLSearchParams(window.location.search).get('verse')
|
||||
if (!urlVerse) {
|
||||
// Maintain scroll position for better UX
|
||||
window.scrollTo({ top: Math.min(scrollPosition, document.body.scrollHeight), behavior: 'auto' })
|
||||
}
|
||||
}, 50) // Small delay for smoother transition
|
||||
})
|
||||
return // Exit early, we have offline content
|
||||
}
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
} catch (offlineError) {
|
||||
console.log('Offline storage not available or chapter not found, falling back to API')
|
||||
}
|
||||
|
||||
// Fallback to API if offline content not available
|
||||
try {
|
||||
const response = await fetch(`/api/bible/verses?bookId=${selectedBook}&chapter=${selectedChapter}`, {
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Only update state if the request wasn't aborted
|
||||
if (!abortController.signal.aborted) {
|
||||
const newVerses = data.verses || []
|
||||
@@ -568,21 +650,23 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
}, 50) // Small delay for smoother transition
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
} catch (err) {
|
||||
// Only log error if it's not an abort error
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error('Error fetching verses:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
loadChapterOfflineFirst()
|
||||
|
||||
// Cleanup function to abort the request if component unmounts or dependencies change
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
}, [selectedBook, selectedChapter])
|
||||
}, [selectedBook, selectedChapter, selectedVersion])
|
||||
|
||||
// Check chapter bookmark status
|
||||
useEffect(() => {
|
||||
@@ -883,6 +967,39 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleVerseMenuOpen = (event: React.MouseEvent<HTMLElement>, verse: BibleVerse) => {
|
||||
setVerseMenuAnchor({
|
||||
element: event.currentTarget,
|
||||
verse: verse
|
||||
})
|
||||
}
|
||||
|
||||
const handleVerseMenuClose = () => {
|
||||
setVerseMenuAnchor({
|
||||
element: null,
|
||||
verse: null
|
||||
})
|
||||
}
|
||||
|
||||
const handleVerseMenuAction = (action: 'bookmark' | 'copy' | 'chat') => {
|
||||
if (!verseMenuAnchor.verse) return
|
||||
|
||||
const verse = verseMenuAnchor.verse
|
||||
handleVerseMenuClose()
|
||||
|
||||
switch (action) {
|
||||
case 'bookmark':
|
||||
handleVerseBookmark(verse)
|
||||
break
|
||||
case 'copy':
|
||||
handleCopyVerse(verse)
|
||||
break
|
||||
case 'chat':
|
||||
handleVerseChat(verse)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getThemeStyles = () => {
|
||||
switch (preferences.theme) {
|
||||
case 'dark':
|
||||
@@ -968,8 +1085,6 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
sx={{
|
||||
opacity: preferences.readingMode ? 0.2 : 0.3,
|
||||
transition: 'opacity 0.2s',
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
'&:hover': {
|
||||
opacity: 1
|
||||
}
|
||||
@@ -977,23 +1092,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleVerseBookmark(verse)}
|
||||
sx={{
|
||||
color: isBookmarked ? 'warning.main' : 'action.active',
|
||||
'&:hover': {
|
||||
backgroundColor: preferences.readingMode
|
||||
? (preferences.theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' :
|
||||
preferences.theme === 'sepia' ? 'rgba(92, 75, 58, 0.1)' :
|
||||
'rgba(0, 0, 0, 0.05)')
|
||||
: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isBookmarked ? <Bookmark fontSize="small" /> : <BookmarkBorder fontSize="small" />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopyVerse(verse)}
|
||||
onClick={(e) => handleVerseMenuOpen(e, verse)}
|
||||
sx={{
|
||||
color: 'action.active',
|
||||
'&:hover': {
|
||||
@@ -1005,23 +1104,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContentCopy fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleVerseChat(verse)}
|
||||
sx={{
|
||||
color: 'action.active',
|
||||
'&:hover': {
|
||||
backgroundColor: preferences.readingMode
|
||||
? (preferences.theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' :
|
||||
preferences.theme === 'sepia' ? 'rgba(92, 75, 58, 0.1)' :
|
||||
'rgba(0, 0, 0, 0.05)')
|
||||
: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Chat fontSize="small" />
|
||||
<MoreVert fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1642,12 +1725,6 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
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}
|
||||
@@ -1666,6 +1743,46 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
{/* PWA Install Prompt */}
|
||||
<InstallPrompt autoShow={true} />
|
||||
|
||||
{/* Verse Menu */}
|
||||
<Menu
|
||||
anchorEl={verseMenuAnchor.element}
|
||||
open={Boolean(verseMenuAnchor.element)}
|
||||
onClose={handleVerseMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={() => handleVerseMenuAction('bookmark')}>
|
||||
<ListItemIcon>
|
||||
{verseMenuAnchor.verse && verseBookmarks[verseMenuAnchor.verse.id] ? (
|
||||
<Bookmark fontSize="small" />
|
||||
) : (
|
||||
<BookmarkBorder fontSize="small" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{verseMenuAnchor.verse && verseBookmarks[verseMenuAnchor.verse.id] ? 'Remove Bookmark' : 'Bookmark'}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleVerseMenuAction('copy')}>
|
||||
<ListItemIcon>
|
||||
<ContentCopy fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Copy Verse</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleVerseMenuAction('chat')}>
|
||||
<ListItemIcon>
|
||||
<Chat fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Ask AI</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Copy Feedback */}
|
||||
<Snackbar
|
||||
open={copyFeedback.open}
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
DialogActions,
|
||||
Chip,
|
||||
Alert,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
CircularProgress
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Download,
|
||||
@@ -31,7 +32,8 @@ import {
|
||||
WifiOff,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Info
|
||||
Info,
|
||||
Refresh
|
||||
} from '@mui/icons-material'
|
||||
import { bibleDownloadManager, type BibleVersion, type DownloadProgress } from '@/lib/offline-storage'
|
||||
|
||||
@@ -69,7 +71,10 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
||||
|
||||
const loadDownloadedVersions = async () => {
|
||||
try {
|
||||
console.log('[OfflineDownloadManager] Loading downloaded versions...')
|
||||
const versions = await bibleDownloadManager.getDownloadedVersions()
|
||||
console.log('[OfflineDownloadManager] Downloaded versions:', versions)
|
||||
console.log('[OfflineDownloadManager] Number of versions:', versions.length)
|
||||
setDownloadedVersions(versions)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load downloaded versions:', error)
|
||||
@@ -97,16 +102,35 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Starting download for version: ${version.name} (${version.id})`)
|
||||
|
||||
try {
|
||||
// Initialize download progress immediately
|
||||
setDownloads(prev => ({
|
||||
...prev,
|
||||
[version.id]: {
|
||||
versionId: version.id,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
totalBooks: 0,
|
||||
downloadedBooks: 0,
|
||||
totalChapters: 0,
|
||||
downloadedChapters: 0,
|
||||
startedAt: new Date().toISOString()
|
||||
}
|
||||
}))
|
||||
|
||||
await bibleDownloadManager.downloadVersion(
|
||||
version.id,
|
||||
(progress: DownloadProgress) => {
|
||||
console.log(`Download progress for ${version.name}:`, progress)
|
||||
setDownloads(prev => ({
|
||||
...prev,
|
||||
[version.id]: progress
|
||||
}))
|
||||
|
||||
if (progress.status === 'completed') {
|
||||
console.log(`Download completed for ${version.name}`)
|
||||
loadDownloadedVersions()
|
||||
loadStorageInfo()
|
||||
onVersionDownloaded?.(version.id)
|
||||
@@ -118,13 +142,33 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
||||
return rest
|
||||
})
|
||||
}, 3000)
|
||||
} else if (progress.status === 'failed') {
|
||||
console.error(`Download failed for ${version.name}:`, progress.error)
|
||||
alert(`Download failed: ${progress.error || 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
console.error('Download failed:', error)
|
||||
const errorMessage = 'Download failed'
|
||||
alert(errorMessage)
|
||||
const errorMessage = (error as Error)?.message || 'Download failed'
|
||||
|
||||
// Update downloads state to show failure
|
||||
setDownloads(prev => ({
|
||||
...prev,
|
||||
[version.id]: {
|
||||
versionId: version.id,
|
||||
status: 'failed' as const,
|
||||
progress: 0,
|
||||
totalBooks: 0,
|
||||
downloadedBooks: 0,
|
||||
totalChapters: 0,
|
||||
downloadedChapters: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
error: errorMessage
|
||||
}
|
||||
}))
|
||||
|
||||
alert(`Download failed: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +210,8 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
||||
}
|
||||
|
||||
const isVersionDownloading = (versionId: string) => {
|
||||
return downloads[versionId]?.status === 'downloading'
|
||||
const status = downloads[versionId]?.status
|
||||
return status === 'downloading' || status === 'pending'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -192,6 +237,61 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Downloaded Versions - Moved to top */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">
|
||||
Downloaded Versions ({downloadedVersions.length})
|
||||
</Typography>
|
||||
<Tooltip title="Refresh list">
|
||||
<IconButton onClick={loadDownloadedVersions} size="small">
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{downloadedVersions.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No Bible versions downloaded yet. Download versions below 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>
|
||||
|
||||
{/* Storage Info */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
@@ -219,45 +319,6 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
||||
</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 }}>
|
||||
@@ -301,53 +362,62 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
||||
</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>
|
||||
) : (
|
||||
{/* Available Versions for Download */}
|
||||
{isOnline && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Available for Download
|
||||
</Typography>
|
||||
<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>
|
||||
)}
|
||||
{availableVersions
|
||||
.filter(version => !isVersionDownloaded(version.id))
|
||||
.map((version) => {
|
||||
const downloadProgress = downloads[version.id]
|
||||
const isDownloading = isVersionDownloading(version.id)
|
||||
|
||||
return (
|
||||
<ListItem key={version.id} sx={{ flexDirection: 'column', alignItems: 'stretch' }}>
|
||||
<Box sx={{ display: 'flex', width: '100%', alignItems: 'center' }}>
|
||||
<ListItemText
|
||||
primary={version.name}
|
||||
secondary={`${version.abbreviation} - ${version.language}`}
|
||||
/>
|
||||
<Button
|
||||
startIcon={isDownloading ? <CircularProgress size={16} /> : <Download />}
|
||||
onClick={() => handleDownload(version)}
|
||||
disabled={isDownloading}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
{isDownloading ? 'Downloading...' : 'Download'}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Delete from offline storage">
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => setConfirmDelete(version.id)}
|
||||
color="error"
|
||||
size="small"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
{isDownloading && downloadProgress && (
|
||||
<Box sx={{ width: '100%', mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={downloadProgress.progress}
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{downloadProgress.downloadedChapters} / {downloadProgress.totalChapters} chapters ({downloadProgress.progress}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{availableVersions.filter(v => !isVersionDownloaded(v.id)).length === 0 && (
|
||||
<Typography color="text.secondary">
|
||||
All available versions are already downloaded
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
|
||||
@@ -125,10 +125,12 @@ class OfflineStorage {
|
||||
|
||||
async getAllVersions(): Promise<BibleVersion[]> {
|
||||
await this.init()
|
||||
return this.performTransaction('versions', 'readonly', (store) => {
|
||||
const versions = await this.performTransaction('versions', 'readonly', (store) => {
|
||||
const versionsStore = store as IDBObjectStore
|
||||
return versionsStore.getAll()
|
||||
})
|
||||
console.log('[OfflineStorage] getAllVersions result:', versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
async deleteVersion(versionId: string): Promise<void> {
|
||||
@@ -409,6 +411,10 @@ export class BibleDownloadManager {
|
||||
}
|
||||
|
||||
const versionData = await versionResponse.json()
|
||||
if (!versionData.success) {
|
||||
throw new Error(versionData.error || 'Failed to fetch version books')
|
||||
}
|
||||
|
||||
const { version, books } = versionData
|
||||
|
||||
progress.totalBooks = books.length
|
||||
@@ -416,10 +422,11 @@ export class BibleDownloadManager {
|
||||
onProgress?.(progress)
|
||||
|
||||
// Save version info
|
||||
console.log('[Download] Saving version info:', version)
|
||||
await offlineStorage.saveVersion(version)
|
||||
|
||||
// Calculate total chapters
|
||||
progress.totalChapters = books.reduce((sum: number, book: any) => sum + book.chaptersCount, 0)
|
||||
// Calculate total chapters from the chapters array in each book
|
||||
progress.totalChapters = books.reduce((sum: number, book: any) => sum + (book.chapters?.length || 0), 0)
|
||||
await offlineStorage.saveDownloadProgress(progress)
|
||||
onProgress?.(progress)
|
||||
|
||||
@@ -427,26 +434,48 @@ export class BibleDownloadManager {
|
||||
for (const book of books) {
|
||||
console.log(`[Download] Downloading book: ${book.name}`)
|
||||
|
||||
await offlineStorage.saveBook(versionId, book)
|
||||
// Map the book data to match our interface
|
||||
const bookData = {
|
||||
id: book.id,
|
||||
name: book.name,
|
||||
abbreviation: book.bookKey,
|
||||
orderNum: book.orderNum,
|
||||
testament: book.testament,
|
||||
chaptersCount: book.chapters?.length || 0
|
||||
}
|
||||
|
||||
await offlineStorage.saveBook(versionId, bookData)
|
||||
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 (book.chapters) {
|
||||
for (const chapterInfo of book.chapters) {
|
||||
const chapterResponse = await fetch(`/api/bible/chapter?book=${book.id}&chapter=${chapterInfo.chapterNum}&version=${versionId}`)
|
||||
|
||||
if (chapterResponse.ok) {
|
||||
const chapterData = await chapterResponse.json()
|
||||
await offlineStorage.saveChapter(versionId, book.id, chapterData.chapter)
|
||||
if (chapterResponse.ok) {
|
||||
const chapterData = await chapterResponse.json()
|
||||
if (chapterData.chapter) {
|
||||
// Map the chapter data to match our interface
|
||||
const mappedChapter = {
|
||||
id: chapterData.chapter.id,
|
||||
bookId: book.id,
|
||||
chapterNum: chapterData.chapter.chapterNum,
|
||||
verseCount: chapterData.chapter.verses.length,
|
||||
verses: chapterData.chapter.verses
|
||||
}
|
||||
await offlineStorage.saveChapter(versionId, book.id, mappedChapter)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,22 @@ const nextConfig = {
|
||||
'**/node_modules/**',
|
||||
'**/bibles/**',
|
||||
'**/scripts/**',
|
||||
'**/csv_bibles/**',
|
||||
'**/*.csv',
|
||||
'**/.git/**',
|
||||
'**/.next/**'
|
||||
]
|
||||
}
|
||||
|
||||
// Add ignore patterns for webpack - exclude entire directories
|
||||
// Add ignore patterns for webpack - exclude entire directories and CSV files
|
||||
config.module.rules.push({
|
||||
test: /[\\/](bibles|scripts)[\\/]/,
|
||||
test: /[\\/](bibles|scripts|csv_bibles)[\\/]/,
|
||||
use: 'ignore-loader'
|
||||
})
|
||||
|
||||
// Ignore CSV files specifically
|
||||
config.module.rules.push({
|
||||
test: /\.csv$/,
|
||||
use: 'ignore-loader'
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user