- Updated HighlightsTab to accept syncStatus and syncErrorMessage props - Added SyncStatusIndicator component import and display in highlights panel - Enhanced BibleReaderApp with sync status tracking state (synced/syncing/pending/error) - Modified performSync function to update sync status based on result - Updated VersDetailsPanel to pass sync status props through to HighlightsTab - Sync status now visible to users in the Highlights tab with real-time updates Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
'use client'
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { useLocale } from 'next-intl'
|
|
import { Box, Typography, Button } from '@mui/material'
|
|
import { BibleChapter, BibleVerse, BibleHighlight, HighlightColor } from '@/types'
|
|
import { getCachedChapter, cacheChapter } from '@/lib/cache-manager'
|
|
import { SearchNavigator } from './search-navigator'
|
|
import { ReadingView } from './reading-view'
|
|
import { VersDetailsPanel } from './verse-details-panel'
|
|
import { ReadingSettings } from './reading-settings'
|
|
import { HighlightSyncManager } from '@/lib/highlight-sync-manager'
|
|
import { addHighlight, updateHighlight, getHighlightsByVerse, deleteHighlight, getAllHighlights } from '@/lib/highlight-manager'
|
|
import { pullAndMergeHighlights } from '@/lib/highlight-pull-sync'
|
|
|
|
interface BookInfo {
|
|
id: string // UUID
|
|
orderNum: number
|
|
bookKey: string
|
|
name: string
|
|
chapterCount: number
|
|
}
|
|
|
|
export function BibleReaderApp() {
|
|
const locale = useLocale()
|
|
const [bookId, setBookId] = useState(1) // Genesis (numeric ID from search)
|
|
const [chapter, setChapter] = useState(1)
|
|
const [currentChapter, setCurrentChapter] = useState<BibleChapter | null>(null)
|
|
const [selectedVerse, setSelectedVerse] = useState<BibleVerse | null>(null)
|
|
const [detailsPanelOpen, setDetailsPanelOpen] = useState(false)
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set())
|
|
const [books, setBooks] = useState<BookInfo[]>([])
|
|
const [versionId, setVersionId] = useState<string>('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [booksLoading, setBooksLoading] = useState(true)
|
|
const [highlights, setHighlights] = useState<Map<string, BibleHighlight>>(new Map())
|
|
const syncManager = useRef<HighlightSyncManager | null>(null)
|
|
const [syncStatus, setSyncStatus] = useState<'synced' | 'syncing' | 'pending' | 'error'>('synced')
|
|
const [syncError, setSyncError] = useState<string | null>(null)
|
|
|
|
// Load books on mount or when locale changes
|
|
useEffect(() => {
|
|
loadBooks()
|
|
}, [locale])
|
|
|
|
// Load chapter when bookId or chapter changes
|
|
useEffect(() => {
|
|
if (!booksLoading && books.length > 0) {
|
|
loadChapter(bookId, chapter)
|
|
}
|
|
}, [bookId, chapter, booksLoading, books.length])
|
|
|
|
// Initialize sync manager on mount
|
|
useEffect(() => {
|
|
syncManager.current = new HighlightSyncManager()
|
|
syncManager.current.init()
|
|
syncManager.current.startAutoSync(30000, () => {
|
|
performSync()
|
|
})
|
|
|
|
return () => {
|
|
syncManager.current?.stopAutoSync()
|
|
}
|
|
}, [])
|
|
|
|
// Pull highlights from server when component mounts (user logged in)
|
|
useEffect(() => {
|
|
const pullHighlights = async () => {
|
|
try {
|
|
const merged = await pullAndMergeHighlights()
|
|
const map = new Map(merged.map(h => [h.verseId, h]))
|
|
setHighlights(map)
|
|
} catch (error) {
|
|
console.error('Failed to pull highlights:', error)
|
|
}
|
|
}
|
|
|
|
pullHighlights()
|
|
}, [])
|
|
|
|
// Load all highlights on mount
|
|
useEffect(() => {
|
|
loadAllHighlights()
|
|
}, [])
|
|
|
|
async function loadBooks() {
|
|
setBooksLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const response = await fetch(`/api/bible/books?locale=${locale}`)
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load books: ${response.status}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
if (data.books && Array.isArray(data.books)) {
|
|
const bookMap: BookInfo[] = data.books.map((book: any) => ({
|
|
id: book.id,
|
|
orderNum: book.orderNum,
|
|
bookKey: book.bookKey,
|
|
name: book.name,
|
|
chapterCount: book.chapters.length
|
|
}))
|
|
setBooks(bookMap)
|
|
setVersionId(data.version?.id || 'unknown')
|
|
} else {
|
|
throw new Error('Invalid books response format')
|
|
}
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error loading books'
|
|
setError(errorMsg)
|
|
console.error('Error loading books:', error)
|
|
} finally {
|
|
setBooksLoading(false)
|
|
}
|
|
}
|
|
|
|
async function loadChapter(numericBookId: number, chapterNum: number) {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const book = books.find(b => b.orderNum === numericBookId)
|
|
if (!book) {
|
|
setError(`Book not found (ID: ${numericBookId})`)
|
|
setCurrentChapter(null)
|
|
return
|
|
}
|
|
|
|
// Try cache first
|
|
const chapterId = `${book.id}-${chapterNum}`
|
|
let data = await getCachedChapter(chapterId)
|
|
|
|
// If not cached, fetch from API
|
|
if (!data) {
|
|
const response = await fetch(`/api/bible/chapter?book=${book.id}&chapter=${chapterNum}`)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load chapter: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
const json = await response.json()
|
|
data = json.chapter
|
|
|
|
// Cache it
|
|
if (data) {
|
|
data.id = chapterId
|
|
await cacheChapter(data).catch(e => console.error('Cache error:', e))
|
|
}
|
|
}
|
|
|
|
setCurrentChapter(data)
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error loading chapter'
|
|
setError(errorMsg)
|
|
setCurrentChapter(null)
|
|
console.error('Error loading chapter:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleVerseClick = (verseId: string) => {
|
|
const verse = currentChapter?.verses.find(v => v.id === verseId)
|
|
if (verse) {
|
|
setSelectedVerse(verse)
|
|
setDetailsPanelOpen(true)
|
|
}
|
|
}
|
|
|
|
const handleToggleBookmark = () => {
|
|
if (!selectedVerse) return
|
|
const newBookmarks = new Set(bookmarks)
|
|
if (newBookmarks.has(selectedVerse.id)) {
|
|
newBookmarks.delete(selectedVerse.id)
|
|
} else {
|
|
newBookmarks.add(selectedVerse.id)
|
|
}
|
|
setBookmarks(newBookmarks)
|
|
// TODO: Sync to backend in Phase 2
|
|
console.log('Bookmarks updated:', Array.from(newBookmarks))
|
|
}
|
|
|
|
useEffect(() => {
|
|
// Persist bookmarks to localStorage
|
|
const bookmarkArray = Array.from(bookmarks)
|
|
localStorage.setItem('bible-reader-bookmarks', JSON.stringify(bookmarkArray))
|
|
}, [bookmarks])
|
|
|
|
// On mount, load bookmarks from localStorage
|
|
useEffect(() => {
|
|
const stored = localStorage.getItem('bible-reader-bookmarks')
|
|
if (stored) {
|
|
try {
|
|
const bookmarkArray = JSON.parse(stored) as string[]
|
|
setBookmarks(new Set(bookmarkArray))
|
|
} catch (e) {
|
|
console.error('Failed to load bookmarks:', e)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const handleAddNote = (note: string) => {
|
|
if (!selectedVerse) return
|
|
// TODO: Save note to backend in Phase 2
|
|
console.log(`Note for verse ${selectedVerse.id}:`, note)
|
|
}
|
|
|
|
async function loadAllHighlights() {
|
|
try {
|
|
const highlightList = await getAllHighlights()
|
|
const map = new Map(highlightList.map(h => [h.verseId, h]))
|
|
setHighlights(map)
|
|
} catch (error) {
|
|
console.error('Failed to load highlights:', error)
|
|
}
|
|
}
|
|
|
|
async function handleHighlightVerse(color: HighlightColor = 'yellow') {
|
|
if (!selectedVerse) return
|
|
|
|
const highlight: BibleHighlight = {
|
|
id: `h-${selectedVerse.id}-${Date.now()}`,
|
|
verseId: selectedVerse.id,
|
|
color,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
syncStatus: 'pending'
|
|
}
|
|
|
|
try {
|
|
await addHighlight(highlight)
|
|
const newMap = new Map(highlights)
|
|
newMap.set(selectedVerse.id, highlight)
|
|
setHighlights(newMap)
|
|
} catch (error) {
|
|
console.error('Failed to highlight verse:', error)
|
|
}
|
|
}
|
|
|
|
async function handleChangeHighlightColor(color: HighlightColor) {
|
|
if (!selectedVerse) return
|
|
|
|
const existing = highlights.get(selectedVerse.id)
|
|
if (existing) {
|
|
const updated = {
|
|
...existing,
|
|
color,
|
|
updatedAt: Date.now(),
|
|
syncStatus: 'pending' as const
|
|
}
|
|
try {
|
|
await updateHighlight(updated)
|
|
const newMap = new Map(highlights)
|
|
newMap.set(selectedVerse.id, updated)
|
|
setHighlights(newMap)
|
|
} catch (error) {
|
|
console.error('Failed to update highlight color:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleRemoveHighlight() {
|
|
if (!selectedVerse) return
|
|
|
|
try {
|
|
// Find and delete all highlights for this verse
|
|
const existing = highlights.get(selectedVerse.id)
|
|
if (existing) {
|
|
await deleteHighlight(existing.id)
|
|
const newMap = new Map(highlights)
|
|
newMap.delete(selectedVerse.id)
|
|
setHighlights(newMap)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to remove highlight:', error)
|
|
}
|
|
}
|
|
|
|
async function performSync() {
|
|
if (!syncManager.current) return
|
|
|
|
try {
|
|
setSyncStatus('syncing')
|
|
const result = await syncManager.current.performSync()
|
|
|
|
if (result.errors > 0) {
|
|
setSyncStatus('error')
|
|
setSyncError(`Failed to sync ${result.errors} highlights`)
|
|
} else {
|
|
setSyncStatus('synced')
|
|
setSyncError(null)
|
|
}
|
|
} catch (error) {
|
|
setSyncStatus('error')
|
|
setSyncError(error instanceof Error ? error.message : 'Unknown error')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'auto', overflow: 'hidden' }}>
|
|
{/* Header with search */}
|
|
<Box
|
|
sx={{
|
|
p: 2,
|
|
bgcolor: 'background.paper',
|
|
boxShadow: 1,
|
|
flexShrink: 0
|
|
}}
|
|
>
|
|
<SearchNavigator
|
|
onNavigate={(newBookId, newChapter) => {
|
|
setBookId(newBookId)
|
|
setChapter(newChapter)
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Reading area */}
|
|
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
|
{!booksLoading && error ? (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
<Typography color="error" variant="h6">{error}</Typography>
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => location.reload()}
|
|
sx={{ mt: 2 }}
|
|
>
|
|
Reload
|
|
</Button>
|
|
</Box>
|
|
) : booksLoading ? (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>Initializing Bible reader...</Box>
|
|
) : loading ? (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>Loading chapter...</Box>
|
|
) : currentChapter ? (
|
|
<ReadingView
|
|
chapter={currentChapter}
|
|
loading={loading}
|
|
onPrevChapter={() => chapter > 1 && setChapter(chapter - 1)}
|
|
onNextChapter={() => {
|
|
const book = books.find(b => b.orderNum === bookId)
|
|
if (book && chapter < book.chapterCount) {
|
|
setChapter(chapter + 1)
|
|
}
|
|
}}
|
|
onVerseClick={handleVerseClick}
|
|
onSettingsOpen={() => setSettingsOpen(true)}
|
|
hasPrevChapter={chapter > 1}
|
|
hasNextChapter={(() => {
|
|
const book = books.find(b => b.orderNum === bookId)
|
|
return book ? chapter < book.chapterCount : false
|
|
})()}
|
|
/>
|
|
) : (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
Failed to load chapter. Please try again.
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Details panel */}
|
|
<VersDetailsPanel
|
|
verse={selectedVerse}
|
|
isOpen={detailsPanelOpen}
|
|
onClose={() => setDetailsPanelOpen(false)}
|
|
isBookmarked={selectedVerse ? bookmarks.has(selectedVerse.id) : false}
|
|
onToggleBookmark={handleToggleBookmark}
|
|
onAddNote={handleAddNote}
|
|
isHighlighted={highlights.has(selectedVerse?.id || '')}
|
|
currentHighlightColor={highlights.get(selectedVerse?.id || '')?.color}
|
|
onHighlightVerse={handleHighlightVerse}
|
|
onChangeHighlightColor={handleChangeHighlightColor}
|
|
onRemoveHighlight={handleRemoveHighlight}
|
|
syncStatus={syncStatus}
|
|
syncErrorMessage={syncError || undefined}
|
|
/>
|
|
|
|
{/* Settings panel */}
|
|
{settingsOpen && (
|
|
<ReadingSettings onClose={() => setSettingsOpen(false)} />
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|