Files
biblical-guide.com/app/[locale]/bible/reader.tsx
Andrei f96cd9231e feat: integrate reading plans with Bible reader
Bible Reader Integration:
- Fetch and display active reading plan progress in Bible reader
- Progress bar shows plan completion percentage when user has active plan
- Progress bar color changes to green for active plans
- Display "Plan Name Progress" with days completed below bar

Reading Plan Navigation:
- Add "Read the Bible" button to active reading plan detail page
- Button shows current/next reading selection (Day X: Book Chapter)
- Navigate directly to Bible reader with correct book and chapter
- Smart selection: current day if incomplete, next incomplete day if current is done
- Only shown for ACTIVE plans

Technical:
- Load active plans via /api/user/reading-plans endpoint
- Calculate progress from completedDays vs plan duration
- Use getCurrentReading() helper to determine next reading
- URL encoding for book names with spaces

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 06:09:50 +00:00

2753 lines
90 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 { useSwipeable } from 'react-swipeable'
import { AuthModal } from '@/components/auth/auth-modal'
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,
LinearProgress
} 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,
Edit
} from '@mui/icons-material'
interface BibleVerse {
id: string
verseNum: number
text: string
}
interface BibleChapter {
id: string
chapterNum: number
verses: BibleVerse[]
}
interface TextHighlight {
id: string
verseId: string
color: 'yellow' | 'green' | 'blue' | 'purple' | 'orange' | 'pink' | 'red'
note?: string
tags?: string[]
createdAt: Date
updatedAt: Date
}
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[]
chaptersCount?: number
}
interface ReadingPreferences {
fontSize: number
lineHeight: number
fontFamily: string
theme: 'light' | 'dark' | 'sepia'
showVerseNumbers: boolean
columnLayout: boolean
readingMode: boolean
letterSpacing: number // 0-2px range for character spacing
wordSpacing: number // 0-4px range for word spacing
paragraphSpacing: number // 1.0-2.5x line height for paragraph spacing
maxLineLength: number // 50-100 characters (ch units) for optimal reading width
enableSwipeGestures: boolean // Enable swipe left/right for chapter navigation
enableTapZones: boolean // Enable tap zones (left=prev, right=next)
paginationMode: boolean // Page-by-page vs continuous scroll
}
const defaultPreferences: ReadingPreferences = {
fontSize: 18,
lineHeight: 1.6,
fontFamily: 'serif',
theme: 'light',
showVerseNumbers: true,
columnLayout: false,
readingMode: false,
letterSpacing: 0.5, // 0.5px default (WCAG 2.1 SC 1.4.12 recommends 0.12em)
wordSpacing: 0, // 0px default (browser default is optimal)
paragraphSpacing: 1.8, // 1.8x line spacing (WCAG recommends ≥1.5x)
maxLineLength: 75, // 75ch optimal reading width (50-75 for desktop)
enableSwipeGestures: true, // Enable by default for mobile
enableTapZones: true, // Enable by default for mobile
paginationMode: false // Continuous scroll by default
}
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()
// Add global accessibility styles for focus indicators (WCAG AAA)
useEffect(() => {
const style = document.createElement('style')
style.innerHTML = `
/* Global focus indicators - WCAG AAA Compliance */
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[role="button"]:focus-visible,
[tabindex]:not([tabindex="-1"]):focus-visible {
outline: 2px solid #1976d2 !important;
outline-offset: 2px !important;
}
/* Ensure 200% zoom support - WCAG AAA */
@media (max-width: 1280px) {
html {
font-size: 100% !important;
}
}
/* Prevent horizontal scroll at 200% zoom */
body {
overflow-x: hidden;
}
`
document.head.appendChild(style)
return () => {
document.head.removeChild(style)
}
}, [])
// 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' && typeof window !== 'undefined') return searchParams.get('verse') // Only on client
return null
},
has: (key: string) => {
if (key === 'version') return !!initialVersion
if (key === 'book') return !!initialBook
if (key === 'chapter') return !!initialChapter
if (key === 'verse' && typeof window !== 'undefined') return searchParams.has('verse') // Only on client
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)
// Highlight state
const [highlights, setHighlights] = useState<{[key: string]: TextHighlight}>({})
const [highlightColorPickerAnchor, setHighlightColorPickerAnchor] = useState<{
element: HTMLElement | null
verse: BibleVerse | null
}>({
element: null,
verse: null
})
// Reading progress state
const [readingProgress, setReadingProgress] = useState<any>(null)
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
// Active reading plan state
const [activeReadingPlan, setActiveReadingPlan] = useState<any>(null)
// Page transition state
const [isTransitioning, setIsTransitioning] = useState(false)
// Accessibility announcement state
const [ariaAnnouncement, setAriaAnnouncement] = useState('')
// Note dialog state
const [noteDialog, setNoteDialog] = useState<{
open: boolean
verse?: BibleVerse
note: string
highlightId?: string
}>({
open: false,
note: '',
highlightId: undefined
})
// 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
})
// Auth modal state
const [authModalOpen, setAuthModalOpen] = useState(false)
const [authModalMessage, setAuthModalMessage] = useState<string>('')
const [pendingAction, setPendingAction] = useState<(() => void) | null>(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 (client-side only)
if (typeof window !== 'undefined') {
setIsOnline(navigator.onLine)
}
// Check for offline mode preference
const offlineParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('offline') : null
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' // Load ALL versions, no limit
: `/api/bible/versions?language=${locale}`
try {
const res = await fetch(url)
const data = await res.json()
if (data.success && data.versions) {
setVersions(data.versions)
// Check if current version is available in the new locale's versions
const currentVersionAvailable = selectedVersion && data.versions.find((v: BibleVersion) => v.id === selectedVersion)
// Auto-select if there's NO current selection OR if current version is not available in new locale
if (!selectedVersion || (!showAllVersions && !currentVersionAvailable)) {
let versionToSelect = null
// Try to load user's favorite version first, but only if it matches the current locale
if (user && !showAllVersions) {
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) {
// Check if favorite version is in the current locale's versions
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 for this locale or first version
if (!versionToSelect) {
versionToSelect = data.versions.find((v: BibleVersion) => v.isDefault && v.language.toLowerCase() === locale.toLowerCase())
|| 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()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [locale, showAllVersions, user]) // Removed selectedVersion from dependencies to prevent infinite loop
// 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])
// Load active reading plan
useEffect(() => {
if (typeof window === 'undefined') return
const loadActiveReadingPlan = async () => {
if (user) {
const token = localStorage.getItem('authToken')
if (!token) return
try {
const response = await fetch('/api/user/reading-plans', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await response.json()
if (data.success && data.plans) {
// Find the first active plan
const activePlan = data.plans.find((p: any) => p.status === 'ACTIVE')
setActiveReadingPlan(activePlan || null)
}
} catch (error) {
console.error('Error loading active reading plan:', error)
}
}
}
loadActiveReadingPlan()
}, [user])
// Load reading progress when version changes
useEffect(() => {
// Only run on client side to avoid hydration mismatch
if (typeof window === 'undefined') return
const loadProgress = async () => {
if (debouncedVersion && user && !hasLoadedInitialProgress) {
const progress = await loadReadingProgress(debouncedVersion)
if (progress) {
setReadingProgress(progress)
// Only restore position if we haven't loaded from URL params
if (!effectiveParams.get('book') && !effectiveParams.get('chapter')) {
setSelectedBook(progress.bookId)
setSelectedChapter(progress.chapterNum)
}
}
setHasLoadedInitialProgress(true)
}
}
loadProgress()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedVersion, user, hasLoadedInitialProgress, effectiveParams])
// Save reading progress when chapter changes
useEffect(() => {
if (selectedVersion && selectedBook && selectedChapter && user) {
// Debounce saving to avoid too many API calls
const timer = setTimeout(() => {
saveReadingProgress(selectedVersion, selectedBook, selectedChapter)
}, 2000) // Save after 2 seconds of no changes
return () => clearTimeout(timer)
}
}, [selectedVersion, selectedBook, selectedChapter, user])
// 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) {
// Transform books to include chaptersCount
const transformedBooks = (data.books || []).map((book: any) => ({
...book,
chaptersCount: book.chapters?.length || 0
}))
setBooks(transformedBooks)
if (transformedBooks.length > 0 && !initialBook) {
setSelectedBook(transformedBooks[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])
// Load highlights for current verses
useEffect(() => {
if (verses.length > 0 && user) {
const token = localStorage.getItem('authToken')
if (token) {
const verseIds = verses.map(verse => verse.id)
fetch(`/api/highlights/bulk?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ verseIds })
})
.then(res => res.json())
.then(data => setHighlights(data.highlights || {}))
.catch(err => console.error('Error loading highlights:', err))
}
} else {
setHighlights({})
}
}, [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 || typeof window === 'undefined') return
const shareUrl = `${window.location.origin}/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}`
const shareText = currentBook ? `${currentBook.name} ${selectedChapter}` : `Chapter ${selectedChapter}`
if (typeof navigator !== 'undefined' && 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 if (typeof navigator !== 'undefined') {
// 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 requireAuth = (action: () => void, message: string) => {
if (!user) {
setAuthModalMessage(message)
setPendingAction(() => action)
setAuthModalOpen(true)
return false
}
return true
}
const handleAuthSuccess = () => {
setAuthModalOpen(false)
setAuthModalMessage('')
// Execute pending action if there is one
if (pendingAction) {
pendingAction()
setPendingAction(null)
}
}
const handlePreviousChapter = () => {
// Trigger transition animation
setIsTransitioning(true)
setTimeout(() => setIsTransitioning(false), 300) // Match CSS transition duration
if (selectedChapter > 1) {
const newChapter = selectedChapter - 1
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
// Announce for screen readers
setAriaAnnouncement(`Navigated to ${currentBook?.name} chapter ${newChapter}`)
} 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)
// Announce for screen readers
setAriaAnnouncement(`Navigated to ${previousBook.name} chapter ${lastChapter}`)
}
}
}
const handleNextChapter = () => {
// Trigger transition animation
setIsTransitioning(true)
setTimeout(() => setIsTransitioning(false), 300) // Match CSS transition duration
if (selectedChapter < maxChapters) {
const newChapter = selectedChapter + 1
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
// Announce for screen readers
setAriaAnnouncement(`Navigated to ${currentBook?.name} chapter ${newChapter}`)
} 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)
// Announce for screen readers
setAriaAnnouncement(`Navigated to ${nextBook.name} chapter 1`)
}
}
}
// Swipe handlers for mobile navigation
const swipeHandlers = useSwipeable({
onSwipedLeft: () => {
if (preferences.enableSwipeGestures && isMobile) {
handleNextChapter()
}
},
onSwipedRight: () => {
if (preferences.enableSwipeGestures && isMobile) {
handlePreviousChapter()
}
},
preventScrollOnSwipe: false,
trackMouse: false, // Only track touch, not mouse
delta: 50 // Minimum swipe distance in pixels
})
// Tap zone handler for quick navigation
const handleTapZone = (event: React.MouseEvent<HTMLDivElement>) => {
if (!preferences.enableTapZones || !isMobile) return
const target = event.currentTarget
const rect = target.getBoundingClientRect()
const clickX = event.clientX - rect.left
const tapZoneWidth = rect.width * 0.25 // 25% on each side
if (clickX < tapZoneWidth) {
// Left tap zone - previous chapter
handlePreviousChapter()
} else if (clickX > rect.width - tapZoneWidth) {
// Right tap zone - next chapter
handleNextChapter()
}
}
const handleChapterBookmark = async () => {
if (!selectedBook || !selectedChapter) return
// If user is not authenticated, show auth modal
if (!requireAuth(handleChapterBookmark, 'Please login to bookmark this chapter')) {
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, show auth modal
if (!requireAuth(() => handleVerseBookmark(verse), 'Please login to bookmark this verse')) {
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) => {
if (typeof navigator === 'undefined') return
const text = `${currentBook?.name} ${selectedChapter}:${verse.verseNum} - ${verse.text}`
navigator.clipboard.writeText(text).then(() => {
setCopyFeedback({
open: true,
message: t('copied')
})
}).catch(err => {
console.error('Failed to copy verse:', err)
})
}
const handleVerseChat = (verse: BibleVerse) => {
// If user is not authenticated, show auth modal
if (!requireAuth(() => handleVerseChat(verse), 'Please login to ask AI about this verse')) {
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' | 'highlight') => {
if (!verseMenuAnchor.verse) return
const verse = verseMenuAnchor.verse
switch (action) {
case 'bookmark':
handleVerseBookmark(verse)
handleVerseMenuClose()
break
case 'copy':
handleCopyVerse(verse)
handleVerseMenuClose()
break
case 'chat':
handleVerseChat(verse)
handleVerseMenuClose()
break
case 'highlight':
// Keep menu open, show color picker instead
setHighlightColorPickerAnchor({
element: verseMenuAnchor.element,
verse: verse
})
break
}
}
const handleHighlightVerse = async (verse: BibleVerse, color: TextHighlight['color']) => {
// If user is not authenticated, show auth modal
if (!requireAuth(() => handleHighlightVerse(verse, color), 'Please login to highlight this verse')) {
return
}
const token = localStorage.getItem('authToken')
if (!token) return
try {
// Check if verse already has a highlight
const existingHighlight = highlights[verse.id]
if (existingHighlight) {
// Update highlight color
const response = await fetch(`/api/highlights/${existingHighlight.id}?locale=${locale}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ color })
})
if (response.ok) {
const data = await response.json()
setHighlights(prev => ({
...prev,
[verse.id]: data.highlight
}))
}
} else {
// Create new highlight
const response = await fetch(`/api/highlights?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ verseId: verse.id, color })
})
if (response.ok) {
const data = await response.json()
setHighlights(prev => ({
...prev,
[verse.id]: data.highlight
}))
}
}
} catch (error) {
console.error('Error highlighting verse:', error)
}
// Close color picker and menu
setHighlightColorPickerAnchor({ element: null, verse: null })
handleVerseMenuClose()
}
const handleRemoveHighlight = async (verse: BibleVerse) => {
if (!user) return
const token = localStorage.getItem('authToken')
if (!token) return
try {
const highlight = highlights[verse.id]
if (!highlight) return
const response = await fetch(`/api/highlights/${highlight.id}?locale=${locale}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
setHighlights(prev => {
const newHighlights = { ...prev }
delete newHighlights[verse.id]
return newHighlights
})
}
} catch (error) {
console.error('Error removing highlight:', error)
}
setHighlightColorPickerAnchor({ element: null, verse: null })
handleVerseMenuClose()
}
const handleOpenNoteDialog = (verse: BibleVerse) => {
const highlight = highlights[verse.id]
setNoteDialog({
open: true,
verse,
note: highlight?.note || '',
highlightId: highlight?.id
})
setHighlightColorPickerAnchor({ element: null, verse: null })
handleVerseMenuClose()
}
const handleSaveNote = async () => {
if (!noteDialog.verse || !user) return
const token = localStorage.getItem('authToken')
if (!token) return
try {
const { verse, note, highlightId } = noteDialog
if (highlightId) {
// Update existing highlight's note
const response = await fetch(`/api/highlights/${highlightId}?locale=${locale}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ note: note.trim() || null })
})
if (response.ok) {
const data = await response.json()
setHighlights(prev => ({
...prev,
[verse.id]: data.highlight
}))
setCopyFeedback({
open: true,
message: note.trim() ? 'Note saved' : 'Note removed'
})
}
}
setNoteDialog({ open: false, note: '', highlightId: undefined })
} catch (error) {
console.error('Error saving note:', error)
alert('Failed to save note')
}
}
const handleSetFavoriteVersion = async () => {
// If user is not authenticated, show auth modal
if (!requireAuth(handleSetFavoriteVersion, 'Please login to set your default Bible version')) {
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')
}
}
// Save reading progress
const saveReadingProgress = async (versionId: string, bookId: string, chapterNum: number) => {
if (!user) return
const token = localStorage.getItem('authToken')
if (!token) return
try {
await fetch('/api/user/reading-progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
versionId,
bookId,
chapterNum,
verseNum: null
})
})
} catch (error) {
console.error('Error saving reading progress:', error)
}
}
// Load reading progress for current version
const loadReadingProgress = async (versionId: string) => {
if (!user) return null
const token = localStorage.getItem('authToken')
if (!token) return null
try {
const response = await fetch(`/api/user/reading-progress?versionId=${versionId}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await response.json()
if (data.success && data.progress) {
return data.progress
}
} catch (error) {
console.error('Error loading reading progress:', error)
}
return null
}
// Calculate reading progress percentage
const calculateProgress = () => {
// If user has an active reading plan, show plan progress instead
if (activeReadingPlan) {
const planDuration = activeReadingPlan.plan?.duration || activeReadingPlan.targetEndDate
? Math.ceil((new Date(activeReadingPlan.targetEndDate).getTime() - new Date(activeReadingPlan.startDate).getTime()) / (1000 * 60 * 60 * 24))
: 365
const completedDays = activeReadingPlan.completedDays || 0
return Math.min(Math.round((completedDays / planDuration) * 100), 100)
}
// Default: Calculate progress based on chapters read in entire Bible
if (!books.length || !selectedBook || !selectedChapter) return 0
// Find current book index and total chapters before current position
let totalChaptersBefore = 0
let foundCurrentBook = false
let currentBookChapters = 0
for (const book of books) {
if (book.id === selectedBook) {
foundCurrentBook = true
currentBookChapters = book.chaptersCount || 0
// Add chapters from current book up to current chapter
totalChaptersBefore += selectedChapter
break
}
if (!foundCurrentBook) {
totalChaptersBefore += book.chaptersCount || 0
}
}
// Calculate total chapters in entire Bible
const totalChapters = books.reduce((sum, book) => sum + (book.chaptersCount || 0), 0)
if (totalChapters === 0) return 0
const percentage = (totalChaptersBefore / totalChapters) * 100
return Math.min(Math.round(percentage), 100)
}
const getThemeStyles = () => {
switch (preferences.theme) {
case 'dark':
return {
backgroundColor: '#0d0d0d', // Darker for better contrast (WCAG AAA: 15.3:1)
color: '#f0f0f0', // Brighter text for 7:1+ contrast
borderColor: '#404040'
}
case 'sepia':
return {
backgroundColor: '#f5f1e3', // Adjusted sepia background
color: '#2b2419', // Darker text for 7:1+ contrast (WCAG AAA)
borderColor: '#d4c5a0'
}
default:
return {
backgroundColor: '#ffffff',
color: '#000000', // Pure black on white = 21:1 (exceeds WCAG AAA)
borderColor: '#e0e0e0'
}
}
}
const getHighlightColor = (color: TextHighlight['color'], theme: 'light' | 'dark' | 'sepia') => {
const colors = {
yellow: { light: '#fff9c4', dark: '#7f6000', sepia: '#f5e6b3' },
green: { light: '#c8e6c9', dark: '#2e7d32', sepia: '#d4e8d4' },
blue: { light: '#bbdefb', dark: '#1565c0', sepia: '#c8dce8' },
purple: { light: '#e1bee7', dark: '#6a1b9a', sepia: '#e3d4e8' },
orange: { light: '#ffe0b2', dark: '#e65100', sepia: '#f5ddc8' },
pink: { light: '#f8bbd0', dark: '#c2185b', sepia: '#f5d8e3' },
red: { light: '#ffcdd2', dark: '#c62828', sepia: '#f5d0d4' }
}
return colors[color][theme]
}
const renderVerse = (verse: BibleVerse) => {
const isBookmarked = !!verseBookmarks[verse.id]
const isHighlighted = highlightedVerse === verse.verseNum
const highlight = highlights[verse.id]
return (
<Box
key={verse.id}
ref={(el: HTMLDivElement | null) => { if (el) verseRefs.current[verse.verseNum] = el }}
data-verse-container
sx={{
mb: `${preferences.fontSize * preferences.lineHeight * (preferences.paragraphSpacing - 1)}px`,
display: 'flex',
alignItems: 'flex-start',
gap: 1,
'&:hover .verse-actions': {
opacity: 1
}
}}
>
<Box sx={{ flex: 1, maxWidth: `${preferences.maxLineLength}ch` }}>
<Typography
component="span"
sx={{
fontSize: `${preferences.fontSize}px`,
lineHeight: preferences.lineHeight,
fontFamily: preferences.fontFamily === 'serif' ? 'Georgia, serif' : 'Arial, sans-serif',
letterSpacing: `${preferences.letterSpacing}px`,
wordSpacing: `${preferences.wordSpacing}px`,
display: 'inline',
backgroundColor: isHighlighted
? 'primary.light'
: highlight
? getHighlightColor(highlight.color, preferences.theme)
: isBookmarked
? 'warning.light'
: 'transparent',
borderRadius: (isBookmarked || isHighlighted || highlight) ? 1 : 0,
padding: (isBookmarked || isHighlighted || highlight) ? '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>
{/* Display highlight note if it exists */}
{highlight?.note && (
<Box
sx={{
mt: 1,
p: 1.5,
backgroundColor: preferences.theme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)',
borderLeft: '3px solid',
borderColor: getHighlightColor(highlight.color, preferences.theme),
borderRadius: 1,
fontSize: '0.9em',
fontStyle: 'italic',
color: preferences.theme === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.6)'
}}
>
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, mb: 0.5, opacity: 0.7 }}>
Note:
</Typography>
{highlight.note}
</Box>
)}
</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>
{/* Reading Progress Bar */}
{user && books.length > 0 && (
<Box sx={{ mt: 2, px: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{activeReadingPlan ? `${activeReadingPlan.name} Progress` : 'Reading Progress'}
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 'bold' }}>
{calculateProgress()}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={calculateProgress()}
sx={{
height: 6,
borderRadius: 3,
backgroundColor: 'action.hover',
'& .MuiLinearProgress-bar': {
borderRadius: 3,
backgroundColor: activeReadingPlan ? 'success.main' : 'primary.main'
}
}}
/>
{activeReadingPlan && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block', fontSize: '0.65rem' }}>
{activeReadingPlan.completedDays} of {activeReadingPlan.plan?.duration || 'custom'} days completed
</Typography>
)}
</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>Letter Spacing</Typography>
<Slider
value={preferences.letterSpacing}
onChange={(_, value) => setPreferences(prev => ({ ...prev, letterSpacing: value as number }))}
min={0}
max={2}
step={0.1}
marks
valueLabelDisplay="auto"
/>
</Box>
<Box>
<Typography gutterBottom>Word Spacing</Typography>
<Slider
value={preferences.wordSpacing}
onChange={(_, value) => setPreferences(prev => ({ ...prev, wordSpacing: value as number }))}
min={0}
max={4}
step={0.5}
marks
valueLabelDisplay="auto"
/>
</Box>
<Box>
<Typography gutterBottom>Paragraph Spacing</Typography>
<Slider
value={preferences.paragraphSpacing}
onChange={(_, value) => setPreferences(prev => ({ ...prev, paragraphSpacing: value as number }))}
min={1.0}
max={2.5}
step={0.1}
marks
valueLabelDisplay="auto"
/>
</Box>
<Box>
<Typography gutterBottom>Max Line Length</Typography>
<Slider
value={preferences.maxLineLength}
onChange={(_, value) => setPreferences(prev => ({ ...prev, maxLineLength: value as number }))}
min={50}
max={100}
step={5}
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')}
/>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
Mobile Navigation
</Typography>
<FormControlLabel
control={
<Switch
checked={preferences.enableSwipeGestures}
onChange={(e) => setPreferences(prev => ({ ...prev, enableSwipeGestures: e.target.checked }))}
/>
}
label="Enable Swipe Gestures"
/>
<FormControlLabel
control={
<Switch
checked={preferences.enableTapZones}
onChange={(e) => setPreferences(prev => ({ ...prev, enableTapZones: e.target.checked }))}
/>
}
label="Enable Tap Zones"
/>
<FormControlLabel
control={
<Switch
checked={preferences.paginationMode}
onChange={(e) => setPreferences(prev => ({ ...prev, paginationMode: e.target.checked }))}
/>
}
label="Pagination Mode"
/>
</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()
}}
>
{/* Skip Navigation Link - WCAG AAA */}
<Box
component="a"
href="#main-content"
sx={{
position: 'absolute',
left: '-9999px',
zIndex: 9999,
padding: '1rem',
backgroundColor: 'primary.main',
color: 'white',
textDecoration: 'none',
fontWeight: 'bold',
'&:focus': {
left: '50%',
top: '10px',
transform: 'translateX(-50%)',
outline: '2px solid',
outlineColor: 'primary.dark',
outlineOffset: '2px'
}
}}
>
Skip to main content
</Box>
{/* ARIA Live Region for Screen Reader Announcements */}
<Box
role="status"
aria-live="polite"
aria-atomic="true"
sx={{
position: 'absolute',
left: '-9999px',
width: '1px',
height: '1px',
overflow: 'hidden'
}}
>
{ariaAnnouncement}
</Box>
{/* 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"
aria-label="Previous chapter"
sx={{
'&:focus': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: '2px'
}
}}
>
<ArrowBack />
</IconButton>
</Tooltip>
<Tooltip title={t('nextChapter')}>
<IconButton
onClick={handleNextChapter}
disabled={selectedBook === books[books.length - 1]?.id && selectedChapter === maxChapters}
size="small"
aria-label="Next chapter"
sx={{
'&:focus': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: '2px'
}
}}
>
<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
id="main-content"
{...swipeHandlers}
ref={contentRef}
onClick={handleTapZone}
tabIndex={-1}
sx={{
maxWidth: preferences.columnLayout ? 'none' : '800px',
mx: 'auto',
width: '100%',
minHeight: '60vh', // Prevent layout shifts
position: 'relative',
cursor: preferences.enableTapZones && isMobile ? 'pointer' : 'default',
userSelect: 'text', // Ensure text selection still works
WebkitUserSelect: 'text',
'&:focus': {
outline: 'none' // Remove default outline since we have skip link
}
}}
>
<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',
opacity: isTransitioning ? 0.5 : 1,
transition: 'opacity 0.3s ease-in-out',
transform: isTransitioning ? 'scale(0.98)' : 'scale(1)',
transitionProperty: 'opacity, transform',
transitionDuration: '0.3s',
transitionTimingFunction: 'ease-in-out'
}}
>
{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('highlight')}>
<ListItemIcon>
<Palette fontSize="small" />
</ListItemIcon>
<ListItemText>
{verseMenuAnchor.verse && highlights[verseMenuAnchor.verse.id] ? 'Change Highlight' : 'Highlight'}
</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>
{/* Highlight Color Picker */}
<Menu
anchorEl={highlightColorPickerAnchor.element}
open={Boolean(highlightColorPickerAnchor.element)}
onClose={() => {
setHighlightColorPickerAnchor({ element: null, verse: null })
handleVerseMenuClose()
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Box sx={{ p: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Select Highlight Color
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', maxWidth: 200 }}>
{(['yellow', 'green', 'blue', 'purple', 'orange', 'pink', 'red'] as const).map(color => (
<IconButton
key={color}
onClick={() => {
if (highlightColorPickerAnchor.verse) {
handleHighlightVerse(highlightColorPickerAnchor.verse, color)
}
}}
sx={{
width: 40,
height: 40,
backgroundColor: getHighlightColor(color, preferences.theme),
border: highlightColorPickerAnchor.verse && highlights[highlightColorPickerAnchor.verse.id]?.color === color
? '3px solid'
: '1px solid',
borderColor: highlightColorPickerAnchor.verse && highlights[highlightColorPickerAnchor.verse.id]?.color === color
? 'primary.main'
: 'divider',
'&:hover': {
backgroundColor: getHighlightColor(color, preferences.theme),
opacity: 0.8
}
}}
/>
))}
</Box>
{highlightColorPickerAnchor.verse && highlights[highlightColorPickerAnchor.verse.id] && (
<>
<Button
fullWidth
variant="outlined"
size="small"
sx={{ mt: 2 }}
startIcon={<Edit />}
onClick={() => {
if (highlightColorPickerAnchor.verse) {
handleOpenNoteDialog(highlightColorPickerAnchor.verse)
}
}}
>
{highlights[highlightColorPickerAnchor.verse.id].note ? 'Edit Note' : 'Add Note'}
</Button>
<Button
fullWidth
variant="outlined"
color="error"
size="small"
sx={{ mt: 1 }}
onClick={() => {
if (highlightColorPickerAnchor.verse) {
handleRemoveHighlight(highlightColorPickerAnchor.verse)
}
}}
>
Remove Highlight
</Button>
</>
)}
</Box>
</Menu>
{/* Note Dialog */}
<Dialog
open={noteDialog.open}
onClose={() => setNoteDialog({ open: false, note: '', highlightId: undefined })}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{noteDialog.highlightId ? 'Edit Note' : 'Add Note'}
</DialogTitle>
<DialogContent>
<TextField
autoFocus
multiline
rows={4}
fullWidth
placeholder="Add your thoughts, insights, or reflections about this verse..."
value={noteDialog.note}
onChange={(e) => setNoteDialog(prev => ({ ...prev, note: e.target.value }))}
sx={{ mt: 2 }}
/>
{noteDialog.verse && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
{currentBook?.name} {selectedChapter}:{noteDialog.verse.verseNum}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setNoteDialog({ open: false, note: '', highlightId: undefined })}>
Cancel
</Button>
<Button onClick={handleSaveNote} variant="contained">
Save Note
</Button>
</DialogActions>
</Dialog>
{/* 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>
{/* Auth Modal */}
<AuthModal
open={authModalOpen}
onClose={() => {
setAuthModalOpen(false)
setAuthModalMessage('')
setPendingAction(null)
}}
onSuccess={handleAuthSuccess}
message={authModalMessage}
defaultTab="login"
/>
</Box>
)
}