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/
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -79,4 +79,11 @@ env/
|
|||||||
.venv/
|
.venv/
|
||||||
|
|
||||||
# Import logs
|
# Import logs
|
||||||
import_log.txt
|
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 { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { OfflineDownloadManager } from '@/components/bible/offline-download-manager'
|
import { OfflineDownloadManager } from '@/components/bible/offline-download-manager'
|
||||||
import { OfflineBibleReader } from '@/components/bible/offline-bible-reader'
|
import { OfflineBibleReader } from '@/components/bible/offline-bible-reader'
|
||||||
|
import { offlineStorage } from '@/lib/offline-storage'
|
||||||
import { InstallPrompt, useInstallPrompt } from '@/components/pwa/install-prompt'
|
import { InstallPrompt, useInstallPrompt } from '@/components/pwa/install-prompt'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -75,7 +76,8 @@ import {
|
|||||||
Chat,
|
Chat,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
Storage
|
Storage,
|
||||||
|
MoreVert
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
|
|
||||||
interface BibleVerse {
|
interface BibleVerse {
|
||||||
@@ -218,6 +220,15 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
message: ''
|
message: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Verse menu state
|
||||||
|
const [verseMenuAnchor, setVerseMenuAnchor] = useState<{
|
||||||
|
element: HTMLElement | null
|
||||||
|
verse: BibleVerse | null
|
||||||
|
}>({
|
||||||
|
element: null,
|
||||||
|
verse: null
|
||||||
|
})
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const verseRefs = useRef<{[key: number]: HTMLDivElement}>({})
|
const verseRefs = useRef<{[key: number]: HTMLDivElement}>({})
|
||||||
@@ -450,16 +461,48 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
// Create an AbortController for this request
|
// Create an AbortController for this request
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|
||||||
fetch(`/api/bible/books?locale=${locale}&version=${debouncedVersion}`, {
|
// First, try to load books from offline storage
|
||||||
signal: abortController.signal
|
const loadBooksOfflineFirst = async () => {
|
||||||
})
|
try {
|
||||||
.then(res => {
|
// Check if we have the books offline
|
||||||
if (!res.ok) {
|
const offlineBooks = await offlineStorage.getBooksForVersion(debouncedVersion)
|
||||||
throw new Error(`HTTP error! status: ${res.status}`)
|
|
||||||
|
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()
|
} catch (offlineError) {
|
||||||
})
|
console.log('Offline storage not available or books not found, falling back to API')
|
||||||
.then(data => {
|
}
|
||||||
|
|
||||||
|
// 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
|
// Only update state if the request wasn't aborted
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
setBooks(data.books || [])
|
setBooks(data.books || [])
|
||||||
@@ -468,14 +511,16 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})
|
} catch (err) {
|
||||||
.catch(err => {
|
|
||||||
// Only log error if it's not an abort error
|
// Only log error if it's not an abort error
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
console.error('Error fetching books:', err)
|
console.error('Error fetching books:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBooksOfflineFirst()
|
||||||
|
|
||||||
// Cleanup function to abort the request if component unmounts or dependencies change
|
// Cleanup function to abort the request if component unmounts or dependencies change
|
||||||
return () => {
|
return () => {
|
||||||
@@ -533,16 +578,53 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
// Create an AbortController for this request
|
// Create an AbortController for this request
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|
||||||
fetch(`/api/bible/verses?bookId=${selectedBook}&chapter=${selectedChapter}`, {
|
// First, try to load from offline storage if available
|
||||||
signal: abortController.signal
|
const loadChapterOfflineFirst = async () => {
|
||||||
})
|
try {
|
||||||
.then(res => {
|
// Check if we have the chapter offline
|
||||||
if (!res.ok) {
|
const offlineChapter = await offlineStorage.getChapter(selectedVersion, selectedBook, selectedChapter)
|
||||||
throw new Error(`HTTP error! status: ${res.status}`)
|
|
||||||
|
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()
|
} catch (offlineError) {
|
||||||
})
|
console.log('Offline storage not available or chapter not found, falling back to API')
|
||||||
.then(data => {
|
}
|
||||||
|
|
||||||
|
// 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
|
// Only update state if the request wasn't aborted
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
const newVerses = data.verses || []
|
const newVerses = data.verses || []
|
||||||
@@ -568,21 +650,23 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}, 50) // Small delay for smoother transition
|
}, 50) // Small delay for smoother transition
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
} catch (err) {
|
||||||
.catch(err => {
|
|
||||||
// Only log error if it's not an abort error
|
// Only log error if it's not an abort error
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
console.error('Error fetching verses:', err)
|
console.error('Error fetching verses:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChapterOfflineFirst()
|
||||||
|
|
||||||
// Cleanup function to abort the request if component unmounts or dependencies change
|
// Cleanup function to abort the request if component unmounts or dependencies change
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedBook, selectedChapter])
|
}, [selectedBook, selectedChapter, selectedVersion])
|
||||||
|
|
||||||
// Check chapter bookmark status
|
// Check chapter bookmark status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -883,6 +967,39 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}, 200)
|
}, 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 = () => {
|
const getThemeStyles = () => {
|
||||||
switch (preferences.theme) {
|
switch (preferences.theme) {
|
||||||
case 'dark':
|
case 'dark':
|
||||||
@@ -968,8 +1085,6 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
sx={{
|
sx={{
|
||||||
opacity: preferences.readingMode ? 0.2 : 0.3,
|
opacity: preferences.readingMode ? 0.2 : 0.3,
|
||||||
transition: 'opacity 0.2s',
|
transition: 'opacity 0.2s',
|
||||||
display: 'flex',
|
|
||||||
gap: 0.5,
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
opacity: 1
|
opacity: 1
|
||||||
}
|
}
|
||||||
@@ -977,23 +1092,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleVerseBookmark(verse)}
|
onClick={(e) => handleVerseMenuOpen(e, 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)}
|
|
||||||
sx={{
|
sx={{
|
||||||
color: 'action.active',
|
color: 'action.active',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -1005,23 +1104,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ContentCopy fontSize="small" />
|
<MoreVert 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" />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1642,12 +1725,6 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
fullWidth
|
fullWidth
|
||||||
fullScreen={isMobile}
|
fullScreen={isMobile}
|
||||||
>
|
>
|
||||||
<DialogTitle>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<Storage color="primary" />
|
|
||||||
Offline Bible Downloads
|
|
||||||
</Box>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent sx={{ p: 0 }}>
|
<DialogContent sx={{ p: 0 }}>
|
||||||
<OfflineDownloadManager
|
<OfflineDownloadManager
|
||||||
availableVersions={versions}
|
availableVersions={versions}
|
||||||
@@ -1666,6 +1743,46 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
{/* PWA Install Prompt */}
|
{/* PWA Install Prompt */}
|
||||||
<InstallPrompt autoShow={true} />
|
<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 */}
|
{/* Copy Feedback */}
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={copyFeedback.open}
|
open={copyFeedback.open}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
Chip,
|
Chip,
|
||||||
Alert,
|
Alert,
|
||||||
Tooltip
|
Tooltip,
|
||||||
|
CircularProgress
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
@@ -31,7 +32,8 @@ import {
|
|||||||
WifiOff,
|
WifiOff,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Error,
|
Error,
|
||||||
Info
|
Info,
|
||||||
|
Refresh
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { bibleDownloadManager, type BibleVersion, type DownloadProgress } from '@/lib/offline-storage'
|
import { bibleDownloadManager, type BibleVersion, type DownloadProgress } from '@/lib/offline-storage'
|
||||||
|
|
||||||
@@ -69,7 +71,10 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
|||||||
|
|
||||||
const loadDownloadedVersions = async () => {
|
const loadDownloadedVersions = async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log('[OfflineDownloadManager] Loading downloaded versions...')
|
||||||
const versions = await bibleDownloadManager.getDownloadedVersions()
|
const versions = await bibleDownloadManager.getDownloadedVersions()
|
||||||
|
console.log('[OfflineDownloadManager] Downloaded versions:', versions)
|
||||||
|
console.log('[OfflineDownloadManager] Number of versions:', versions.length)
|
||||||
setDownloadedVersions(versions)
|
setDownloadedVersions(versions)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to load downloaded versions:', error)
|
console.error('Failed to load downloaded versions:', error)
|
||||||
@@ -97,16 +102,35 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Starting download for version: ${version.name} (${version.id})`)
|
||||||
|
|
||||||
try {
|
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(
|
await bibleDownloadManager.downloadVersion(
|
||||||
version.id,
|
version.id,
|
||||||
(progress: DownloadProgress) => {
|
(progress: DownloadProgress) => {
|
||||||
|
console.log(`Download progress for ${version.name}:`, progress)
|
||||||
setDownloads(prev => ({
|
setDownloads(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[version.id]: progress
|
[version.id]: progress
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (progress.status === 'completed') {
|
if (progress.status === 'completed') {
|
||||||
|
console.log(`Download completed for ${version.name}`)
|
||||||
loadDownloadedVersions()
|
loadDownloadedVersions()
|
||||||
loadStorageInfo()
|
loadStorageInfo()
|
||||||
onVersionDownloaded?.(version.id)
|
onVersionDownloaded?.(version.id)
|
||||||
@@ -118,13 +142,33 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
|||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
}, 3000)
|
}, 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) {
|
} catch (error: unknown) {
|
||||||
console.error('Download failed:', error)
|
console.error('Download failed:', error)
|
||||||
const errorMessage = 'Download failed'
|
const errorMessage = (error as Error)?.message || 'Download failed'
|
||||||
alert(errorMessage)
|
|
||||||
|
// 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) => {
|
const isVersionDownloading = (versionId: string) => {
|
||||||
return downloads[versionId]?.status === 'downloading'
|
const status = downloads[versionId]?.status
|
||||||
|
return status === 'downloading' || status === 'pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -192,6 +237,61 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</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 */}
|
{/* Storage Info */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -219,45 +319,6 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Active Downloads */}
|
||||||
{Object.keys(downloads).length > 0 && (
|
{Object.keys(downloads).length > 0 && (
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
@@ -301,53 +362,62 @@ export function OfflineDownloadManager({ availableVersions, onVersionDownloaded
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Downloaded Versions */}
|
{/* Available Versions for Download */}
|
||||||
<Card>
|
{isOnline && (
|
||||||
<CardContent>
|
<Card sx={{ mb: 3 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<CardContent>
|
||||||
Downloaded Versions
|
<Typography variant="h6" gutterBottom>
|
||||||
</Typography>
|
Available for Download
|
||||||
{downloadedVersions.length === 0 ? (
|
</Typography>
|
||||||
<Alert severity="info">
|
|
||||||
No Bible versions downloaded yet. Download versions above to read offline.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<List dense>
|
<List dense>
|
||||||
{downloadedVersions.map((version) => (
|
{availableVersions
|
||||||
<ListItem key={version.id}>
|
.filter(version => !isVersionDownloaded(version.id))
|
||||||
<ListItemText
|
.map((version) => {
|
||||||
primary={version.name}
|
const downloadProgress = downloads[version.id]
|
||||||
secondary={
|
const isDownloading = isVersionDownloading(version.id)
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
return (
|
||||||
{version.abbreviation} - {version.language}
|
<ListItem key={version.id} sx={{ flexDirection: 'column', alignItems: 'stretch' }}>
|
||||||
</Typography>
|
<Box sx={{ display: 'flex', width: '100%', alignItems: 'center' }}>
|
||||||
{version.downloadedAt && (
|
<ListItemText
|
||||||
<Typography variant="caption" color="text.secondary">
|
primary={version.name}
|
||||||
Downloaded: {new Date(version.downloadedAt).toLocaleDateString()}
|
secondary={`${version.abbreviation} - ${version.language}`}
|
||||||
</Typography>
|
/>
|
||||||
)}
|
<Button
|
||||||
|
startIcon={isDownloading ? <CircularProgress size={16} /> : <Download />}
|
||||||
|
onClick={() => handleDownload(version)}
|
||||||
|
disabled={isDownloading}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: 2 }}
|
||||||
|
>
|
||||||
|
{isDownloading ? 'Downloading...' : 'Download'}
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
{isDownloading && downloadProgress && (
|
||||||
/>
|
<Box sx={{ width: '100%', mt: 1 }}>
|
||||||
<ListItemSecondaryAction>
|
<LinearProgress
|
||||||
<Tooltip title="Delete from offline storage">
|
variant="determinate"
|
||||||
<IconButton
|
value={downloadProgress.progress}
|
||||||
edge="end"
|
sx={{ mb: 0.5 }}
|
||||||
onClick={() => setConfirmDelete(version.id)}
|
/>
|
||||||
color="error"
|
<Typography variant="caption" color="text.secondary">
|
||||||
size="small"
|
{downloadProgress.downloadedChapters} / {downloadProgress.totalChapters} chapters ({downloadProgress.progress}%)
|
||||||
>
|
</Typography>
|
||||||
<Delete />
|
</Box>
|
||||||
</IconButton>
|
)}
|
||||||
</Tooltip>
|
</ListItem>
|
||||||
</ListItemSecondaryAction>
|
)
|
||||||
</ListItem>
|
})}
|
||||||
))}
|
|
||||||
</List>
|
</List>
|
||||||
)}
|
{availableVersions.filter(v => !isVersionDownloaded(v.id)).length === 0 && (
|
||||||
</CardContent>
|
<Typography color="text.secondary">
|
||||||
</Card>
|
All available versions are already downloaded
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -125,10 +125,12 @@ class OfflineStorage {
|
|||||||
|
|
||||||
async getAllVersions(): Promise<BibleVersion[]> {
|
async getAllVersions(): Promise<BibleVersion[]> {
|
||||||
await this.init()
|
await this.init()
|
||||||
return this.performTransaction('versions', 'readonly', (store) => {
|
const versions = await this.performTransaction('versions', 'readonly', (store) => {
|
||||||
const versionsStore = store as IDBObjectStore
|
const versionsStore = store as IDBObjectStore
|
||||||
return versionsStore.getAll()
|
return versionsStore.getAll()
|
||||||
})
|
})
|
||||||
|
console.log('[OfflineStorage] getAllVersions result:', versions)
|
||||||
|
return versions
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteVersion(versionId: string): Promise<void> {
|
async deleteVersion(versionId: string): Promise<void> {
|
||||||
@@ -409,6 +411,10 @@ export class BibleDownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const versionData = await versionResponse.json()
|
const versionData = await versionResponse.json()
|
||||||
|
if (!versionData.success) {
|
||||||
|
throw new Error(versionData.error || 'Failed to fetch version books')
|
||||||
|
}
|
||||||
|
|
||||||
const { version, books } = versionData
|
const { version, books } = versionData
|
||||||
|
|
||||||
progress.totalBooks = books.length
|
progress.totalBooks = books.length
|
||||||
@@ -416,10 +422,11 @@ export class BibleDownloadManager {
|
|||||||
onProgress?.(progress)
|
onProgress?.(progress)
|
||||||
|
|
||||||
// Save version info
|
// Save version info
|
||||||
|
console.log('[Download] Saving version info:', version)
|
||||||
await offlineStorage.saveVersion(version)
|
await offlineStorage.saveVersion(version)
|
||||||
|
|
||||||
// Calculate total chapters
|
// Calculate total chapters from the chapters array in each book
|
||||||
progress.totalChapters = books.reduce((sum: number, book: any) => sum + book.chaptersCount, 0)
|
progress.totalChapters = books.reduce((sum: number, book: any) => sum + (book.chapters?.length || 0), 0)
|
||||||
await offlineStorage.saveDownloadProgress(progress)
|
await offlineStorage.saveDownloadProgress(progress)
|
||||||
onProgress?.(progress)
|
onProgress?.(progress)
|
||||||
|
|
||||||
@@ -427,26 +434,48 @@ export class BibleDownloadManager {
|
|||||||
for (const book of books) {
|
for (const book of books) {
|
||||||
console.log(`[Download] Downloading book: ${book.name}`)
|
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++
|
progress.downloadedBooks++
|
||||||
|
|
||||||
// Download all chapters for this book
|
// Download all chapters for this book
|
||||||
for (let chapterNum = 1; chapterNum <= book.chaptersCount; chapterNum++) {
|
if (book.chapters) {
|
||||||
const chapterResponse = await fetch(`/api/bible/chapter?book=${book.id}&chapter=${chapterNum}&version=${versionId}`)
|
for (const chapterInfo of book.chapters) {
|
||||||
|
const chapterResponse = await fetch(`/api/bible/chapter?book=${book.id}&chapter=${chapterInfo.chapterNum}&version=${versionId}`)
|
||||||
|
|
||||||
if (chapterResponse.ok) {
|
if (chapterResponse.ok) {
|
||||||
const chapterData = await chapterResponse.json()
|
const chapterData = await chapterResponse.json()
|
||||||
await offlineStorage.saveChapter(versionId, book.id, chapterData.chapter)
|
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/**',
|
'**/node_modules/**',
|
||||||
'**/bibles/**',
|
'**/bibles/**',
|
||||||
'**/scripts/**',
|
'**/scripts/**',
|
||||||
|
'**/csv_bibles/**',
|
||||||
|
'**/*.csv',
|
||||||
'**/.git/**',
|
'**/.git/**',
|
||||||
'**/.next/**'
|
'**/.next/**'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ignore patterns for webpack - exclude entire directories
|
// Add ignore patterns for webpack - exclude entire directories and CSV files
|
||||||
config.module.rules.push({
|
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'
|
use: 'ignore-loader'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user