Files
biblical-guide.com/app/[locale]/bible/reader.tsx
Andrei 2ae2f029ec feat: add user favorite Bible version preference system
- Add favoriteBibleVersion field to User schema in Prisma
- Create API endpoint (/api/user/favorite-version) to get/set favorite version
  - GET: retrieve user's favorite Bible version
  - POST: save/update user's favorite Bible version

- Enhance Bible reader functionality
  - Automatically load user's favorite version on mount (logged-in users)
  - Add star button () next to version selector to set current version as default
  - Display success message when favorite version is saved
  - Fall back to default version if no favorite is set

- Add Bible Preferences section to Settings page
  - Display user's current favorite Bible version in dropdown
  - Allow users to view and change favorite version
  - Load all Bible versions (removed 200 limit) to ensure favorite is found
  - Show confirmation message when version is selected
  - Add loading state while fetching versions

- Fix renderValue in Select component to properly display version names
- Add comprehensive debug logging for troubleshooting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 09:50:01 +00:00

1878 lines
60 KiB
TypeScript

'use client'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslations, useLocale } from 'next-intl'
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,
Typography,
IconButton,
Drawer,
List,
ListItem,
ListItemButton,
ListItemText,
AppBar,
Toolbar,
Button,
Menu,
MenuItem,
Slider,
Switch,
FormControlLabel,
Tooltip,
Fab,
Paper,
Divider,
Chip,
ButtonGroup,
useTheme,
useMediaQuery,
Collapse,
ListItemIcon,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Snackbar,
Alert,
Backdrop,
CircularProgress,
Grid,
FormControl,
InputLabel,
Select,
Container,
Autocomplete
} from '@mui/material'
import {
Menu as MenuIcon,
Settings,
Bookmark,
BookmarkBorder,
Share,
ContentCopy,
ArrowBack,
ArrowForward,
FullscreenExit,
Fullscreen,
KeyboardArrowUp,
KeyboardArrowDown,
FormatSize,
Palette,
Note,
Close,
ExpandLess,
ExpandMore,
MenuBook,
Visibility,
Speed,
Chat,
CloudDownload,
WifiOff,
Storage,
MoreVert,
Star,
StarBorder
} from '@mui/icons-material'
interface BibleVerse {
id: string
verseNum: number
text: string
}
interface BibleChapter {
id: string
chapterNum: number
verses: BibleVerse[]
}
interface BibleVersion {
id: string
name: string
abbreviation: string
language: string
isDefault?: boolean
}
interface BibleBook {
id: string
versionId: string
name: string
testament: string
orderNum: number
bookKey: string
chapters: BibleChapter[]
}
interface ReadingPreferences {
fontSize: number
lineHeight: number
fontFamily: string
theme: 'light' | 'dark' | 'sepia'
showVerseNumbers: boolean
columnLayout: boolean
readingMode: boolean
}
const defaultPreferences: ReadingPreferences = {
fontSize: 18,
lineHeight: 1.6,
fontFamily: 'serif',
theme: 'light',
showVerseNumbers: true,
columnLayout: false,
readingMode: false
}
interface BibleReaderProps {
initialVersion?: string
initialBook?: string
initialChapter?: string
}
export default function BibleReaderNew({ initialVersion, initialBook, initialChapter }: BibleReaderProps = {}) {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const t = useTranslations('pages.bible')
const locale = useLocale()
const router = useRouter()
const searchParams = useSearchParams()
const { user } = useAuth()
// Use initial props if provided, otherwise use search params
const effectiveParams = React.useMemo(() => {
if (initialVersion || initialBook || initialChapter) {
// Create a params-like object from the initial props
return {
get: (key: string) => {
if (key === 'version') return initialVersion || null
if (key === 'book') return initialBook || null
if (key === 'chapter') return initialChapter || null
if (key === 'verse') return searchParams.get('verse') // Still get verse from URL query params
return null
},
has: (key: string) => {
if (key === 'version') return !!initialVersion
if (key === 'book') return !!initialBook
if (key === 'chapter') return !!initialChapter
if (key === 'verse') return searchParams.has('verse')
return false
},
toString: (): string => ''
}
}
return searchParams
}, [initialVersion, initialBook, initialChapter, searchParams])
// Core state
const [books, setBooks] = useState<BibleBook[]>([])
const [versions, setVersions] = useState<BibleVersion[]>([])
const [selectedVersion, setSelectedVersion] = useState<string>('')
const [selectedBook, setSelectedBook] = useState<string>('')
const [selectedChapter, setSelectedChapter] = useState<number>(1)
const [verses, setVerses] = useState<BibleVerse[]>([])
const [loading, setLoading] = useState(false)
const [versionsLoading, setVersionsLoading] = useState(true)
const [showAllVersions, setShowAllVersions] = useState(false)
// Debounced version state to prevent rapid API calls
const [debouncedVersion, setDebouncedVersion] = useState<string>('')
// UI state
const [settingsOpen, setSettingsOpen] = useState(false)
const [preferences, setPreferences] = useState<ReadingPreferences>(defaultPreferences)
const [highlightedVerse, setHighlightedVerse] = useState<number | null>(null)
const [showScrollTop, setShowScrollTop] = useState(false)
const [previousVerses, setPreviousVerses] = useState<BibleVerse[]>([]) // Keep previous content during loading
// Offline/PWA state
const [isOnline, setIsOnline] = useState(true)
const [isOfflineMode, setIsOfflineMode] = useState(false)
const [offlineDialogOpen, setOfflineDialogOpen] = useState(false)
// Bookmark state
const [isChapterBookmarked, setIsChapterBookmarked] = useState(false)
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
const [bookmarkLoading, setBookmarkLoading] = useState(false)
// Note dialog state
const [noteDialog, setNoteDialog] = useState<{
open: boolean
verse?: BibleVerse
note: string
}>({
open: false,
note: ''
})
// Copy feedback
const [copyFeedback, setCopyFeedback] = useState<{
open: boolean
message: string
}>({
open: false,
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}>({})
// PWA install prompt
const { canInstall, isInstalled, showInstallPrompt } = useInstallPrompt()
// Load user preferences from localStorage
useEffect(() => {
const savedPrefs = localStorage.getItem('bibleReaderPreferences')
if (savedPrefs) {
try {
const parsed = JSON.parse(savedPrefs)
setPreferences({ ...defaultPreferences, ...parsed })
} catch (e) {
console.error('Failed to parse preferences:', e)
}
}
// Load saved version preference
const savedVersion = localStorage.getItem('selectedBibleVersion')
if (savedVersion && versions.length > 0) {
const version = versions.find(v => v.id === savedVersion)
if (version) {
setSelectedVersion(savedVersion)
}
}
}, [versions])
// Save preferences to localStorage
useEffect(() => {
localStorage.setItem('bibleReaderPreferences', JSON.stringify(preferences))
}, [preferences])
// Handle full screen mode - add/remove CSS class to body
useEffect(() => {
if (preferences.readingMode) {
document.body.classList.add('bible-fullscreen-mode')
} else {
document.body.classList.remove('bible-fullscreen-mode')
}
// Cleanup on unmount
return () => {
document.body.classList.remove('bible-fullscreen-mode')
}
}, [preferences.readingMode])
// Save selected version to localStorage
useEffect(() => {
if (selectedVersion) {
localStorage.setItem('selectedBibleVersion', selectedVersion)
}
}, [selectedVersion])
// Scroll handler for show scroll to top button
useEffect(() => {
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Online/offline detection
useEffect(() => {
const handleOnline = () => {
setIsOnline(true)
if (isOfflineMode) {
// Show notification that connection is restored
console.log('Connection restored, you can now access all features')
}
}
const handleOffline = () => {
setIsOnline(false)
console.log('You are now offline. Only downloaded content is available.')
}
// Set initial state
setIsOnline(navigator.onLine)
// Check for offline mode preference
const offlineParam = new URLSearchParams(window.location.search).get('offline')
if (offlineParam === 'true') {
setIsOfflineMode(true)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [isOfflineMode])
// Fetch versions based on showAllVersions state and locale
useEffect(() => {
const loadVersions = async () => {
setVersionsLoading(true)
const url = showAllVersions
? '/api/bible/versions?all=true&limit=200' // Limit to first 200 for performance
: `/api/bible/versions?language=${locale}`
try {
const res = await fetch(url)
const data = await res.json()
if (data.success && data.versions) {
setVersions(data.versions)
// Keep current selection if it exists in new list, otherwise select default/first
const currentVersionExists = data.versions.some((v: BibleVersion) => v.id === selectedVersion)
if (!currentVersionExists || !selectedVersion) {
// Try to load user's favorite version first
let versionToSelect = null
if (user) {
const token = localStorage.getItem('authToken')
if (token) {
try {
const favoriteRes = await fetch('/api/user/favorite-version', {
headers: { 'Authorization': `Bearer ${token}` }
})
const favoriteData = await favoriteRes.json()
if (favoriteData.success && favoriteData.favoriteBibleVersion) {
const favoriteVersion = data.versions.find((v: BibleVersion) => v.id === favoriteData.favoriteBibleVersion)
if (favoriteVersion) {
versionToSelect = favoriteVersion
}
}
} catch (error) {
console.error('Error loading favorite version:', error)
}
}
}
// Fall back to default version or first version
if (!versionToSelect) {
versionToSelect = data.versions.find((v: BibleVersion) => v.isDefault) || data.versions[0]
}
if (versionToSelect) {
setSelectedVersion(versionToSelect.id)
}
}
}
setVersionsLoading(false)
} catch (err) {
console.error('Error fetching versions:', err)
setVersionsLoading(false)
}
}
loadVersions()
}, [locale, showAllVersions, selectedVersion, user])
// Handle URL parameters for bookmark navigation
useEffect(() => {
const urlVersion = effectiveParams.get('version')
const urlBook = effectiveParams.get('book')
const urlChapter = effectiveParams.get('chapter')
const urlVerse = effectiveParams.get('verse')
if (urlVersion && versions.length > 0) {
// Check if this version exists
const version = versions.find(v => v.id === urlVersion)
if (version && selectedVersion !== urlVersion) {
setSelectedVersion(urlVersion)
}
}
if (urlBook && books.length > 0) {
const book = books.find(b => b.id === urlBook)
if (book && selectedBook !== urlBook) {
setSelectedBook(urlBook)
}
}
if (urlChapter) {
const chapter = parseInt(urlChapter)
if (chapter && selectedChapter !== chapter) {
setSelectedChapter(chapter)
}
}
if (urlVerse && verses.length > 0) {
const verseNum = parseInt(urlVerse)
if (verseNum) {
// Highlight the verse
setTimeout(() => {
const verseElement = verseRefs.current[verseNum]
if (verseElement) {
verseElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
setHighlightedVerse(verseNum)
}
}, 500)
}
}
}, [effectiveParams, versions, books, verses, selectedVersion, selectedBook, selectedChapter])
// Function to update URL without causing full page reload
const updateUrl = useCallback(async (bookId?: string, chapter?: number, versionId?: string) => {
const targetVersionId = versionId || selectedVersion
const targetBookId = bookId || selectedBook
const targetChapter = chapter || selectedChapter
// Try to generate SEO-friendly URL
try {
const version = versions.find(v => v.id === targetVersionId)
const book = books.find(b => b.id === targetBookId)
if (version && book && targetChapter) {
// Generate SEO-friendly URL
const versionSlug = version.abbreviation.toLowerCase()
const bookSlug = book.bookKey.toLowerCase()
const newUrl = `/${locale}/bible/${versionSlug}/${bookSlug}/${targetChapter}`
router.replace(newUrl, { scroll: false })
return
}
} catch (error) {
console.error('Error generating SEO-friendly URL:', error)
}
// Fallback to query parameter URL
const params = new URLSearchParams()
if (targetVersionId) {
params.set('version', targetVersionId)
}
if (targetBookId) {
params.set('book', targetBookId)
}
if (targetChapter) {
params.set('chapter', String(targetChapter))
}
const newUrl = `/${locale}/bible?${params.toString()}`
router.replace(newUrl, { scroll: false })
}, [locale, selectedVersion, selectedBook, selectedChapter, router, versions, books])
// Debounce version changes to prevent rapid API calls
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedVersion(selectedVersion)
}, 300) // 300ms delay
return () => clearTimeout(timer)
}, [selectedVersion])
// Show loading immediately when selectedVersion changes (before debounce)
useEffect(() => {
if (selectedVersion && selectedVersion !== debouncedVersion) {
setLoading(true)
}
}, [selectedVersion, debouncedVersion])
// Fetch books when debounced version changes
useEffect(() => {
if (debouncedVersion) {
// Create an AbortController for this request
const abortController = new AbortController()
// 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
}
} 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 || [])
if (data.books && data.books.length > 0 && !initialBook) {
setSelectedBook(data.books[0].id)
}
setLoading(false)
}
} 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 () => {
abortController.abort()
}
}
}, [locale, debouncedVersion, initialBook])
// Handle URL parameters (only when not using initial props from SEO URLs)
useEffect(() => {
if (books.length > 0 && versions.length > 0 && !initialVersion && !initialBook && !initialChapter) {
const bookParam = searchParams.get('book')
const chapterParam = searchParams.get('chapter')
const verseParam = searchParams.get('verse')
const versionParam = searchParams.get('version')
// Handle version parameter
if (versionParam && versions.find(v => v.id === versionParam)) {
setSelectedVersion(versionParam)
}
if (bookParam) {
const book = books.find(b => b.id === bookParam) || books.find(b => b.bookKey === bookParam)
if (book) {
setSelectedBook(book.id)
if (chapterParam) {
const chapter = parseInt(chapterParam)
if (chapter > 0) {
setSelectedChapter(chapter)
}
}
if (verseParam) {
const verse = parseInt(verseParam)
if (verse > 0) {
setHighlightedVerse(verse)
setTimeout(() => {
scrollToVerse(verse)
setTimeout(() => setHighlightedVerse(null), 3000)
}, 500)
}
}
}
}
}
}, [books, versions, searchParams, initialVersion, initialBook, initialChapter])
// Fetch verses when book/chapter changes
useEffect(() => {
if (selectedBook && selectedChapter) {
setLoading(true)
// Store scroll position to prevent jumping
const scrollPosition = window.pageYOffset
// Create an AbortController for this request
const abortController = new AbortController()
// 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
}
} 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 || []
// Store previous verses before updating
setPreviousVerses(verses)
// Use requestAnimationFrame to ensure smooth transition
requestAnimationFrame(() => {
setVerses(newVerses)
// 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
})
}
} 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, selectedVersion])
// Check chapter bookmark status
useEffect(() => {
if (selectedBook && selectedChapter && user) {
const token = localStorage.getItem('authToken')
if (token) {
fetch(`/api/bookmarks/chapter/check?bookId=${selectedBook}&chapterNum=${selectedChapter}&locale=${locale}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.json())
.then(data => setIsChapterBookmarked(data.isBookmarked || false))
.catch(err => console.error('Error checking bookmark:', err))
}
} else {
setIsChapterBookmarked(false)
}
}, [selectedBook, selectedChapter, user, locale])
// Check verse bookmarks
useEffect(() => {
if (verses.length > 0 && user) {
const token = localStorage.getItem('authToken')
if (token) {
const verseIds = verses.map(verse => verse.id)
fetch(`/api/bookmarks/verse/bulk-check?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ verseIds })
})
.then(res => res.json())
.then(data => setVerseBookmarks(data.bookmarks || {}))
.catch(err => console.error('Error checking verse bookmarks:', err))
}
} else {
setVerseBookmarks({})
}
}, [verses, user, locale])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts if user is typing in an input field
const activeElement = document.activeElement as HTMLElement
if (activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable ||
activeElement.closest('[role="dialog"]') // Don't trigger if a dialog/modal is open
)) {
return
}
// Check if the floating chat is open
const floatingChat = document.querySelector('[data-floating-chat="true"]')
if (floatingChat) {
return
}
if (e.ctrlKey || e.metaKey) return
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
handlePreviousChapter()
break
case 'ArrowRight':
e.preventDefault()
handleNextChapter()
break
case 's':
e.preventDefault()
setSettingsOpen(prev => !prev)
break
case 'r':
e.preventDefault()
setPreferences(prev => ({ ...prev, readingMode: !prev.readingMode }))
break
case 'Escape':
e.preventDefault()
if (preferences.readingMode) {
setPreferences(prev => ({ ...prev, readingMode: false }))
}
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedBook, selectedChapter, books, preferences.readingMode])
const currentBook = books.find(book => book.id === selectedBook)
const maxChapters = currentBook?.chapters?.length || 1
const handleShare = async () => {
if (!selectedBook || !selectedChapter) return
const shareUrl = `${window.location.origin}/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}`
const shareText = currentBook ? `${currentBook.name} ${selectedChapter}` : `Chapter ${selectedChapter}`
if (navigator.share) {
try {
await navigator.share({
title: shareText,
url: shareUrl
})
} catch (error) {
// User cancelled sharing or fallback to clipboard
try {
await navigator.clipboard.writeText(shareUrl)
} catch (clipError) {
console.error('Failed to copy link:', clipError)
}
}
} else {
// Fallback: copy to clipboard
try {
await navigator.clipboard.writeText(shareUrl)
} catch (error) {
console.error('Failed to copy link:', error)
}
}
}
const scrollToVerse = (verseNum: number) => {
const verseElement = verseRefs.current[verseNum]
if (verseElement) {
verseElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
const handlePreviousChapter = () => {
if (selectedChapter > 1) {
const newChapter = selectedChapter - 1
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
} else {
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
if (currentBookIndex > 0) {
const previousBook = books[currentBookIndex - 1]
const lastChapter = previousBook.chapters?.length || 1
setSelectedBook(previousBook.id)
setSelectedChapter(lastChapter)
updateUrl(previousBook.id, lastChapter, selectedVersion)
}
}
}
const handleNextChapter = () => {
if (selectedChapter < maxChapters) {
const newChapter = selectedChapter + 1
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
} else {
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
if (currentBookIndex < books.length - 1) {
const nextBook = books[currentBookIndex + 1]
setSelectedBook(nextBook.id)
setSelectedChapter(1)
updateUrl(nextBook.id, 1, selectedVersion)
}
}
}
const handleChapterBookmark = async () => {
if (!selectedBook || !selectedChapter) return
// If user is not authenticated, redirect to login
if (!user) {
router.push(`/${locale}/login?redirect=${encodeURIComponent(`/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}`)}`)
return
}
setBookmarkLoading(true)
const token = localStorage.getItem('authToken')
if (!token) {
setBookmarkLoading(false)
return
}
try {
if (isChapterBookmarked) {
const response = await fetch(`/api/bookmarks/chapter?bookId=${selectedBook}&chapterNum=${selectedChapter}&locale=${locale}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
setIsChapterBookmarked(false)
}
} else {
const response = await fetch(`/api/bookmarks/chapter?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
bookId: selectedBook,
chapterNum: selectedChapter
})
})
if (response.ok) {
setIsChapterBookmarked(true)
}
}
} catch (error) {
console.error('Error toggling bookmark:', error)
} finally {
setBookmarkLoading(false)
}
}
const handleVerseBookmark = async (verse: BibleVerse) => {
// If user is not authenticated, redirect to login
if (!user) {
router.push(`/${locale}/login?redirect=${encodeURIComponent(`/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}&verse=${verse.verseNum}`)}`)
return
}
const token = localStorage.getItem('authToken')
if (!token) return
try {
const isCurrentlyBookmarked = !!verseBookmarks[verse.id]
if (isCurrentlyBookmarked) {
const response = await fetch(`/api/bookmarks/verse?verseId=${verse.id}&locale=${locale}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
setVerseBookmarks(prev => {
const newBookmarks = { ...prev }
delete newBookmarks[verse.id]
return newBookmarks
})
}
} else {
const response = await fetch(`/api/bookmarks/verse?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ verseId: verse.id })
})
if (response.ok) {
const data = await response.json()
setVerseBookmarks(prev => ({
...prev,
[verse.id]: data.bookmark
}))
}
}
} catch (error) {
console.error('Error toggling verse bookmark:', error)
}
}
const handleCopyVerse = (verse: BibleVerse) => {
const text = `${currentBook?.name} ${selectedChapter}:${verse.verseNum} - ${verse.text}`
navigator.clipboard.writeText(text).then(() => {
setCopyFeedback({
open: true,
message: t('copied')
})
})
}
const handleVerseChat = (verse: BibleVerse) => {
// If user is not authenticated, redirect to login
if (!user) {
router.push(`/${locale}/login?redirect=${encodeURIComponent(`/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}&verse=${verse.verseNum}`)}`)
return
}
// Exit full-screen mode if currently in reading mode so chat is visible
if (preferences.readingMode) {
setPreferences(prev => ({ ...prev, readingMode: false }))
}
const versionName = versions.find(v => v.id === selectedVersion)?.name || selectedVersion
const bookName = currentBook?.name || 'Unknown Book'
const initialMessage = `Explain in depth this verse "${verse.text}" from ${versionName}, ${bookName} ${selectedChapter}:${verse.verseNum} and its meaning`
// Small delay to allow full-screen exit animation to complete
setTimeout(() => {
// Dispatch event to open floating chat with the pre-filled message
window.dispatchEvent(new CustomEvent('floating-chat:open', {
detail: {
initialMessage: initialMessage,
fullscreen: false
}
}))
}, 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 handleSetFavoriteVersion = async () => {
if (!user) {
router.push(`/${locale}/login?redirect=${encodeURIComponent(`/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}`)}`)
return
}
const token = localStorage.getItem('authToken')
if (!token) return
try {
const response = await fetch('/api/user/favorite-version', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ versionId: selectedVersion })
})
const data = await response.json()
if (data.success) {
setCopyFeedback({
open: true,
message: 'This version has been set as your default'
})
} else {
alert('Failed to set favorite version')
}
} catch (error) {
console.error('Error setting favorite version:', error)
alert('Failed to set favorite version')
}
}
const getThemeStyles = () => {
switch (preferences.theme) {
case 'dark':
return {
backgroundColor: '#1a1a1a',
color: '#e0e0e0',
borderColor: '#333'
}
case 'sepia':
return {
backgroundColor: '#f7f3e9',
color: '#5c4b3a',
borderColor: '#d4c5a0'
}
default:
return {
backgroundColor: '#ffffff',
color: '#000000',
borderColor: '#e0e0e0'
}
}
}
const renderVerse = (verse: BibleVerse) => {
const isBookmarked = !!verseBookmarks[verse.id]
const isHighlighted = highlightedVerse === verse.verseNum
return (
<Box
key={verse.id}
ref={(el: HTMLDivElement | null) => { if (el) verseRefs.current[verse.verseNum] = el }}
data-verse-container
sx={{
mb: 1,
display: 'flex',
alignItems: 'flex-start',
gap: 1,
'&:hover .verse-actions': {
opacity: 1
}
}}
>
<Box sx={{ flex: 1 }}>
<Typography
component="span"
sx={{
fontSize: `${preferences.fontSize}px`,
lineHeight: preferences.lineHeight,
fontFamily: preferences.fontFamily === 'serif' ? 'Georgia, serif' : 'Arial, sans-serif',
display: 'inline',
backgroundColor: isHighlighted
? 'primary.light'
: isBookmarked
? 'warning.light'
: 'transparent',
borderRadius: (isBookmarked || isHighlighted) ? 1 : 0,
padding: (isBookmarked || isHighlighted) ? '4px 8px' : 0,
transition: 'all 0.3s ease',
border: isHighlighted ? '2px solid' : 'none',
borderColor: 'primary.main',
}}
>
{preferences.showVerseNumbers && (
<Typography
component="span"
sx={{
fontWeight: 'bold',
color: 'primary.main',
mr: 1,
fontSize: '0.9em',
userSelect: 'none'
}}
>
{verse.verseNum}
</Typography>
)}
{verse.text}
</Typography>
</Box>
<Box
className="verse-actions"
sx={{
opacity: preferences.readingMode ? 0.2 : 0.3,
transition: 'opacity 0.2s',
'&:hover': {
opacity: 1
}
}}
>
<IconButton
size="small"
onClick={(e) => handleVerseMenuOpen(e, 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'
}
}}
>
<MoreVert fontSize="small" />
</IconButton>
</Box>
</Box>
)
}
const renderNavigation = () => (
<Paper
elevation={preferences.readingMode ? 0 : 1}
sx={{
mb: preferences.readingMode ? 1 : 2,
p: preferences.readingMode ? 1 : 2,
...getThemeStyles(),
border: preferences.readingMode
? `1px solid ${preferences.theme === 'dark' ? 'rgba(255, 255, 255, 0.1)' :
preferences.theme === 'sepia' ? 'rgba(92, 75, 58, 0.1)' :
'rgba(0, 0, 0, 0.1)'}`
: `1px solid ${getThemeStyles().borderColor}`,
backgroundColor: preferences.readingMode
? (preferences.theme === 'dark' ? 'rgba(26, 26, 26, 0.95)' :
preferences.theme === 'sepia' ? 'rgba(247, 243, 233, 0.95)' :
'rgba(255, 255, 255, 0.95)')
: getThemeStyles().backgroundColor,
backdropFilter: preferences.readingMode ? 'blur(8px)' : 'none',
position: 'sticky',
top: 0,
zIndex: 1,
transition: 'all 0.2s ease'
}}
>
{/* First Row: Navigation Filters */}
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start', justifyContent: 'center', mb: 2 }}>
{/* Version Selection */}
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 180, md: 200 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Autocomplete
size="small"
fullWidth
value={versions.find(v => v.id === selectedVersion) || null}
onChange={(event, newValue) => {
if (newValue) {
setSelectedVersion(newValue.id)
// Reset to first book when version changes
if (books.length > 0) {
setSelectedBook(books[0].id)
setSelectedChapter(1)
updateUrl(books[0].id, 1, newValue.id)
}
}
}}
options={versions}
getOptionLabel={(option) => `${option.name} - ${option.abbreviation}`}
renderInput={(params) => (
<TextField
{...params}
label={t('version')}
placeholder="Search versions..."
/>
)}
renderOption={(props, option) => (
<Box component="li" {...props}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{option.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{option.abbreviation} {option.language.toUpperCase()}
</Typography>
</Box>
</Box>
)}
disabled={versionsLoading}
loading={versionsLoading}
filterOptions={(options, { inputValue }) => {
const filtered = options.filter((option) =>
option.name.toLowerCase().includes(inputValue.toLowerCase()) ||
option.abbreviation.toLowerCase().includes(inputValue.toLowerCase()) ||
option.language.toLowerCase().includes(inputValue.toLowerCase())
)
return filtered
}}
ListboxProps={{
style: { maxHeight: 400 }
}}
/>
<Tooltip title={user ? 'Set as my default version' : 'Login to set default version'}>
<IconButton
size="small"
onClick={handleSetFavoriteVersion}
sx={{ color: 'warning.main' }}
>
<Star />
</IconButton>
</Tooltip>
<Tooltip title={showAllVersions ? 'Show language versions only' : 'Show all versions'}>
<IconButton
size="small"
onClick={() => setShowAllVersions(prev => !prev)}
sx={{ color: showAllVersions ? 'primary.main' : 'inherit' }}
>
<Visibility />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* Books Selection */}
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 200, md: 250 } }}>
<Autocomplete
size="small"
fullWidth
value={books.find(b => b.id === selectedBook) || null}
onChange={(event, newValue) => {
if (newValue) {
setSelectedBook(newValue.id)
setSelectedChapter(1)
updateUrl(newValue.id, 1, selectedVersion)
}
}}
options={books}
getOptionLabel={(option) => option.name}
renderInput={(params) => (
<TextField
{...params}
label={t('book')}
placeholder="Search books..."
/>
)}
renderOption={(props, option) => (
<Box component="li" {...props}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{option.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{option.testament} {option.chapters?.length || 0} chapters
</Typography>
</Box>
</Box>
)}
filterOptions={(options, { inputValue }) => {
return options.filter((option) =>
option.name.toLowerCase().includes(inputValue.toLowerCase()) ||
option.testament.toLowerCase().includes(inputValue.toLowerCase())
)
}}
ListboxProps={{
style: { maxHeight: 300 }
}}
/>
</Box>
{/* Chapter Selection */}
<Box sx={{ flex: { xs: '1 1 100%', sm: '0 1 auto' }, minWidth: { sm: 120, md: 150 } }}>
<FormControl fullWidth size="small">
<InputLabel>{t('chapter')}</InputLabel>
<Select
value={selectedChapter}
label={t('chapter')}
onChange={(e) => {
const newChapter = Number(e.target.value)
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
}}
MenuProps={{
PaperProps: {
style: {
maxHeight: 400,
},
},
}}
>
{Array.from({ length: maxChapters }, (_, i) => (
<MenuItem key={i + 1} value={i + 1}>
{t('chapter')} {i + 1}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Box>
{/* Second Row: Settings and Controls */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
{/* Font Size Controls */}
<IconButton
size="small"
onClick={() => setPreferences(prev => ({
...prev,
fontSize: Math.max(12, prev.fontSize - 1)
}))}
disabled={preferences.fontSize <= 12}
>
<Typography variant="h6">A</Typography>
</IconButton>
<IconButton
size="small"
onClick={() => setPreferences(prev => ({
...prev,
fontSize: Math.min(28, prev.fontSize + 1)
}))}
disabled={preferences.fontSize >= 28}
>
<Typography variant="h6">A</Typography>
</IconButton>
{/* Action Buttons */}
<Tooltip title={t('toggleFullscreen')}>
<IconButton
size="small"
onClick={() => setPreferences(prev => ({ ...prev, readingMode: !prev.readingMode }))}
sx={{ color: preferences.readingMode ? 'primary.main' : 'inherit' }}
>
{preferences.readingMode ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
<Tooltip title={t('settings')}>
<IconButton size="small" onClick={() => setSettingsOpen(true)}>
<Settings />
</IconButton>
</Tooltip>
<Tooltip title={user ? (isChapterBookmarked ? 'Remove bookmark' : 'Add bookmark') : 'Login to bookmark'}>
<IconButton
size="small"
onClick={handleChapterBookmark}
disabled={bookmarkLoading}
sx={{ color: isChapterBookmarked && user ? 'warning.main' : 'inherit' }}
>
{(isChapterBookmarked && user) ? <Bookmark /> : <BookmarkBorder />}
</IconButton>
</Tooltip>
<Tooltip title="Share">
<IconButton size="small" onClick={handleShare}>
<Share />
</IconButton>
</Tooltip>
<Tooltip title="Offline Downloads">
<IconButton
size="small"
onClick={() => setOfflineDialogOpen(true)}
sx={{ color: !isOnline ? 'warning.main' : 'inherit' }}
>
{isOnline ? <CloudDownload /> : <WifiOff />}
</IconButton>
</Tooltip>
{canInstall && !isInstalled && (
<Tooltip title="Install App">
<IconButton size="small" onClick={showInstallPrompt}>
<Storage />
</IconButton>
</Tooltip>
)}
</Box>
</Paper>
)
const renderSettings = () => (
<Dialog
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{t('readingSettings')}
</DialogTitle>
<DialogContent>
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography gutterBottom>{t('fontSize')}</Typography>
<Slider
value={preferences.fontSize}
onChange={(_, value) => setPreferences(prev => ({ ...prev, fontSize: value as number }))}
min={12}
max={24}
marks
valueLabelDisplay="auto"
/>
</Box>
<Box>
<Typography gutterBottom>{t('lineHeight')}</Typography>
<Slider
value={preferences.lineHeight}
onChange={(_, value) => setPreferences(prev => ({ ...prev, lineHeight: value as number }))}
min={1.2}
max={2.0}
step={0.1}
marks
valueLabelDisplay="auto"
/>
</Box>
<Box>
<Typography gutterBottom>{t('fontFamily')}</Typography>
<ButtonGroup fullWidth>
<Button
variant={preferences.fontFamily === 'serif' ? 'contained' : 'outlined'}
onClick={() => setPreferences(prev => ({ ...prev, fontFamily: 'serif' }))}
>
Serif
</Button>
<Button
variant={preferences.fontFamily === 'sans-serif' ? 'contained' : 'outlined'}
onClick={() => setPreferences(prev => ({ ...prev, fontFamily: 'sans-serif' }))}
>
Sans-serif
</Button>
</ButtonGroup>
</Box>
<Box>
<Typography gutterBottom>{t('theme')}</Typography>
<ButtonGroup fullWidth>
<Button
variant={preferences.theme === 'light' ? 'contained' : 'outlined'}
onClick={() => setPreferences(prev => ({ ...prev, theme: 'light' }))}
>
{t('light')}
</Button>
<Button
variant={preferences.theme === 'dark' ? 'contained' : 'outlined'}
onClick={() => setPreferences(prev => ({ ...prev, theme: 'dark' }))}
>
{t('dark')}
</Button>
<Button
variant={preferences.theme === 'sepia' ? 'contained' : 'outlined'}
onClick={() => setPreferences(prev => ({ ...prev, theme: 'sepia' }))}
>
{t('sepia')}
</Button>
</ButtonGroup>
</Box>
<FormControlLabel
control={
<Switch
checked={preferences.showVerseNumbers}
onChange={(e) => setPreferences(prev => ({ ...prev, showVerseNumbers: e.target.checked }))}
/>
}
label={t('showVerseNumbers')}
/>
<FormControlLabel
control={
<Switch
checked={preferences.readingMode}
onChange={(e) => setPreferences(prev => ({ ...prev, readingMode: e.target.checked }))}
/>
}
label={t('readingMode')}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setSettingsOpen(false)}>
{t('close')}
</Button>
</DialogActions>
</Dialog>
)
// Always render the UI - loading will be handled within components
return (
<Box
sx={{
minHeight: '100vh',
...getThemeStyles()
}}
>
{/* Top Toolbar - Simplified */}
{!preferences.readingMode && (
<AppBar position="static" sx={{ ...getThemeStyles(), boxShadow: 1 }}>
<Toolbar variant="dense">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
{currentBook?.name} {selectedChapter}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={t('previousChapter')}>
<IconButton
onClick={handlePreviousChapter}
disabled={selectedBook === books[0]?.id && selectedChapter === 1}
size="small"
>
<ArrowBack />
</IconButton>
</Tooltip>
<Tooltip title={t('nextChapter')}>
<IconButton
onClick={handleNextChapter}
disabled={selectedBook === books[books.length - 1]?.id && selectedChapter === maxChapters}
size="small"
>
<ArrowForward />
</IconButton>
</Tooltip>
</Box>
</Toolbar>
</AppBar>
)}
{/* Main Content */}
<Container
maxWidth="lg"
sx={{
py: preferences.readingMode ? 2 : 3,
px: preferences.readingMode ? 2 : 3
}}
>
{/* Navigation Section - Always show but with different styling in reading mode */}
{renderNavigation()}
{/* Reading Content */}
<Box
ref={contentRef}
sx={{
maxWidth: preferences.columnLayout ? 'none' : '800px',
mx: 'auto',
width: '100%',
minHeight: '60vh', // Prevent layout shifts
position: 'relative'
}}
>
<Paper
elevation={preferences.readingMode ? 0 : 1}
sx={{
...getThemeStyles(),
borderRadius: preferences.readingMode ? 0 : 2,
p: preferences.readingMode ? 4 : 3,
minHeight: preferences.readingMode ? '100vh' : '60vh', // Consistent minimum height
border: preferences.readingMode ? 'none' : `1px solid ${getThemeStyles().borderColor}`,
position: 'relative'
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 2
}}
>
<CircularProgress />
</Box>
)}
<Box sx={{ opacity: loading ? 0.3 : 1, transition: 'opacity 0.3s ease' }}>
{/* Chapter Header */}
<Box sx={{ mb: 4, textAlign: 'center' }}>
{loading && !currentBook ? (
// Skeleton loading for chapter header
<>
<Box
sx={{
height: 40,
backgroundColor: 'action.hover',
borderRadius: 1,
mb: 2,
margin: '0 auto',
width: '200px'
}}
/>
<Box
sx={{
height: 16,
backgroundColor: 'action.hover',
borderRadius: 1,
margin: '0 auto',
width: '80px'
}}
/>
</>
) : (
<>
<Typography
variant="h3"
component="h1"
sx={{
mb: 2,
fontFamily: preferences.fontFamily === 'serif' ? 'Georgia, serif' : 'Arial, sans-serif'
}}
>
{currentBook?.name} {selectedChapter}
</Typography>
<Typography variant="body2" color="text.secondary">
{(loading && previousVerses.length > 0 ? previousVerses : verses).length} {t('verses')}
</Typography>
</>
)}
</Box>
{/* Verses */}
<Box sx={{ mb: 4 }}>
{loading && verses.length === 0 && previousVerses.length === 0 ? (
// Skeleton loading for verses
<>
{Array.from({ length: 8 }).map((_, index) => (
<Box key={`skeleton-${index}`} sx={{ mb: 2, display: 'flex', alignItems: 'flex-start' }}>
<Box
sx={{
width: 32,
height: 20,
backgroundColor: 'action.hover',
borderRadius: 1,
mr: 2,
flexShrink: 0
}}
/>
<Box sx={{ width: '100%' }}>
<Box
sx={{
height: 16,
backgroundColor: 'action.hover',
borderRadius: 1,
mb: 1,
width: `${Math.random() * 40 + 60}%`
}}
/>
<Box
sx={{
height: 16,
backgroundColor: 'action.hover',
borderRadius: 1,
mb: 1,
width: `${Math.random() * 50 + 40}%`
}}
/>
{Math.random() > 0.5 && (
<Box
sx={{
height: 16,
backgroundColor: 'action.hover',
borderRadius: 1,
width: `${Math.random() * 30 + 20}%`
}}
/>
)}
</Box>
</Box>
))}
</>
) : (
(loading && previousVerses.length > 0 ? previousVerses : verses).map(renderVerse)
)}
</Box>
{/* Chapter Navigation */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4, pt: 3, borderTop: 1, borderColor: 'divider' }}>
<Button
startIcon={<ArrowBack />}
onClick={handlePreviousChapter}
disabled={selectedBook === books[0]?.id && selectedChapter === 1}
variant="outlined"
>
{t('previousChapter')}
</Button>
<Button
endIcon={<ArrowForward />}
onClick={handleNextChapter}
disabled={selectedBook === books[books.length - 1]?.id && selectedChapter === maxChapters}
variant="outlined"
>
{t('nextChapter')}
</Button>
</Box>
</Box>
</Paper>
</Box>
</Container>
{/* Floating Action Buttons */}
{!preferences.readingMode && (
<>
{showScrollTop && (
<Fab
color="primary"
size="small"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
sx={{
position: 'fixed',
bottom: 16,
right: 16
}}
>
<KeyboardArrowUp />
</Fab>
)}
</>
)}
{preferences.readingMode && (
<Fab
color="secondary"
size="small"
onClick={() => setPreferences(prev => ({ ...prev, readingMode: false }))}
sx={{
position: 'fixed',
top: 16,
right: 16
}}
>
<FullscreenExit />
</Fab>
)}
{/* Settings Dialog */}
{renderSettings()}
{/* Offline Downloads Dialog */}
<Dialog
open={offlineDialogOpen}
onClose={() => setOfflineDialogOpen(false)}
maxWidth="md"
fullWidth
fullScreen={isMobile}
>
<DialogContent sx={{ p: 0 }}>
<OfflineDownloadManager
availableVersions={versions}
onVersionDownloaded={(versionId) => {
console.log(`Version ${versionId} downloaded successfully`)
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOfflineDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
{/* 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}
autoHideDuration={3000}
onClose={() => setCopyFeedback({ open: false, message: '' })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" onClose={() => setCopyFeedback({ open: false, message: '' })}>
{copyFeedback.message}
</Alert>
</Snackbar>
</Box>
)
}