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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user