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:
2025-09-30 08:23:22 +00:00
parent 44831a096f
commit fee36dfdad
7 changed files with 424 additions and 178 deletions

View File

@@ -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}