Implemented comprehensive mobile gesture support for the Bible reader: **Swipe Gestures:** - Swipe left/right to navigate between chapters - Only activates on mobile devices (touch events) - Configurable 50px minimum swipe distance - Prevents scrolling interference **Tap Zones:** - Left 25% of screen: navigate to previous chapter - Right 25% of screen: navigate to next chapter - Center 50%: normal reading interaction - Maintains text selection capabilities **Smooth Page Transitions:** - Fade and scale animation on chapter navigation - 300ms duration with ease-in-out timing - Visual feedback: opacity 0.5 and scale 0.98 during transition - Applied to all navigation methods (swipe, tap, keyboard, buttons) **Settings Controls:** - Enable/disable swipe gestures toggle - Enable/disable tap zones toggle - Pagination mode toggle (for future enhancement) - All settings persist in localStorage **Dependencies:** - Added react-swipeable v7.0.2 for gesture handling - Zero-dependency, lightweight (peer deps: React only) **User Experience:** - Settings grouped under "Mobile Navigation" section - Default enabled for optimal mobile UX - Touch-optimized for tablets and phones - Desktop users can disable if desired This completes all mobile navigation features from Phase 1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2565 lines
84 KiB
TypeScript
2565 lines
84 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 {
|
|
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()
|
|
|
|
// 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)
|
|
|
|
// Page transition state
|
|
const [isTransitioning, setIsTransitioning] = useState(false)
|
|
|
|
// 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
|
|
})
|
|
|
|
// 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 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 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)
|
|
} 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 = () => {
|
|
// 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)
|
|
} 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, 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) => {
|
|
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, 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' | '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, 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 {
|
|
// 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) {
|
|
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')
|
|
}
|
|
}
|
|
|
|
// 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 (!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: '#1a1a1a',
|
|
color: '#e0e0e0',
|
|
borderColor: '#333'
|
|
}
|
|
case 'sepia':
|
|
return {
|
|
backgroundColor: '#f7f3e9',
|
|
color: '#5c4b3a',
|
|
borderColor: '#d4c5a0'
|
|
}
|
|
default:
|
|
return {
|
|
backgroundColor: '#ffffff',
|
|
color: '#000000',
|
|
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">
|
|
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: 'primary.main'
|
|
}
|
|
}}
|
|
/>
|
|
</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()
|
|
}}
|
|
>
|
|
{/* 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
|
|
{...swipeHandlers}
|
|
ref={contentRef}
|
|
onClick={handleTapZone}
|
|
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'
|
|
}}
|
|
>
|
|
<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>
|
|
</Box>
|
|
)
|
|
}
|