feat: integrate all Bible reader 2025 components into main app

This completes Task 5 of the Bible Reader 2025 implementation plan,
integrating all previously built components into a cohesive reading experience.

Components added:
- BibleReaderApp: Main orchestrator component with state management
- ReadingSettings: Settings panel with presets and customization options

Key features:
- Chapter navigation with prev/next controls
- SearchNavigator integration for book/chapter lookup
- ReadingView with customizable reading preferences
- VersDetailsPanel for verse interactions (notes, bookmarks)
- ReadingSettings panel with 4 presets and custom controls
- IndexedDB caching for offline chapter access
- Mobile-responsive bottom sheet and desktop sidebar layouts

The app now provides:
- Bookmark management (client-side Set for now, backend sync in Phase 2)
- Note taking (console logging for now, persistence in Phase 2)
- Font customization (4 font families including dyslexia-friendly)
- Size and spacing controls (font size 12-32px, line height 1.4-2.2x)
- Background themes (warm, white, light gray, dark)
- Preset modes (default, dyslexia, high contrast, minimal)

Technical implementation:
- State management via React hooks (useState, useEffect)
- Cache-first loading strategy with API fallback
- Storage events for cross-component preference updates
- TypeScript with proper type annotations
- Material-UI components for consistent styling

Next steps (Phase 2):
- Backend persistence for bookmarks and notes
- Sync annotations across devices
- Highlight system with color selection
- Cross-references integration

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-11 20:12:41 +00:00
parent 5500965563
commit aefe54751b
3 changed files with 332 additions and 62 deletions

View File

@@ -1,66 +1,10 @@
import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import BibleReader from './reader'
import { prisma } from '@/lib/db'
import { BibleReaderApp } from '@/components/bible/bible-reader-app'
interface PageProps {
searchParams: Promise<{
version?: string
book?: string
chapter?: string
verse?: string
}>
params: Promise<{
locale: string
}>
export const metadata = {
title: 'Read Bible',
description: 'Modern Bible reader with offline support'
}
// Helper function to convert UUIDs to SEO-friendly slugs
async function convertToSeoUrl(versionId: string, bookId: string, chapter: string, locale: string) {
try {
const version = await prisma.bibleVersion.findUnique({
where: { id: versionId }
})
const book = await prisma.bibleBook.findUnique({
where: { id: bookId }
})
if (version && book) {
const versionSlug = version.abbreviation.toLowerCase()
const bookSlug = book.bookKey.toLowerCase()
return `/${locale}/bible/${versionSlug}/${bookSlug}/${chapter}`
}
} catch (error) {
console.error('Error converting to SEO URL:', error)
}
return null
}
export default async function BiblePage({ searchParams, params }: PageProps) {
const { version, book, chapter } = await searchParams
const { locale } = await params
// If we have the old URL format with UUIDs, redirect to SEO-friendly URL
if (version && book && chapter) {
const seoUrl = await convertToSeoUrl(version, book, chapter, locale)
if (seoUrl) {
redirect(seoUrl)
}
}
return (
<Suspense fallback={
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '200px'
}}>
Loading Bible reader...
</div>
}>
<BibleReader />
</Suspense>
)
export default function BiblePage() {
return <BibleReaderApp />
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useState, useEffect } from 'react'
import { Box } from '@mui/material'
import { BibleChapter, BibleVerse } 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'
export function BibleReaderApp() {
const [bookId, setBookId] = useState(1) // Genesis
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(false)
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set())
// Load chapter on navigation
useEffect(() => {
loadChapter(bookId, chapter)
}, [bookId, chapter])
async function loadChapter(bookId: number, chapterNum: number) {
setLoading(true)
try {
// Try cache first
const chapterId = `${bookId}-${chapterNum}`
let data = await getCachedChapter(chapterId)
// If not cached, fetch from API
if (!data) {
const response = await fetch(`/api/bible/chapter?book=${bookId}&chapter=${chapterNum}`)
if (response.ok) {
const json = await response.json()
data = json.chapter
// Cache it
if (data) {
data.id = chapterId
await cacheChapter(data)
}
} else {
console.error('Failed to load chapter:', response.status)
}
}
setCurrentChapter(data)
} catch (error) {
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))
}
const handleAddNote = (note: string) => {
if (!selectedVerse) return
// TODO: Save note to backend in Phase 2
console.log(`Note for verse ${selectedVerse.id}:`, note)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
{/* Header with search */}
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 50,
p: 2,
bgcolor: 'background.paper',
boxShadow: 1
}}
>
<SearchNavigator
onNavigate={(newBookId, newChapter) => {
setBookId(newBookId)
setChapter(newChapter)
}}
/>
</Box>
{/* Reading area */}
<Box sx={{ flex: 1, mt: '80px', overflow: 'auto' }}>
{currentChapter ? (
<ReadingView
chapter={currentChapter}
loading={loading}
onPrevChapter={() => chapter > 1 && setChapter(chapter - 1)}
onNextChapter={() => setChapter(chapter + 1)}
onVerseClick={handleVerseClick}
onSettingsOpen={() => setSettingsOpen(true)}
hasPrevChapter={chapter > 1}
hasNextChapter={true} // TODO: Check actual chapter count based on book
/>
) : (
<Box sx={{ p: 4, textAlign: 'center' }}>
{loading ? 'Loading Bible reader...' : '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}
/>
{/* Settings panel */}
{settingsOpen && (
<ReadingSettings onClose={() => setSettingsOpen(false)} />
)}
</Box>
)
}

View File

@@ -0,0 +1,182 @@
'use client'
import { useState, useEffect } from 'react'
import { Box, Paper, Typography, Button, Slider, FormControl, InputLabel, Select, MenuItem, useMediaQuery, useTheme, IconButton } from '@mui/material'
import { Close } from '@mui/icons-material'
import { ReadingPreference } from '@/types'
import { getPreset, loadPreferences, savePreferences } from '@/lib/reading-preferences'
const FONTS = [
{ value: 'georgia', label: 'Georgia (Serif)' },
{ value: 'merriweather', label: 'Merriweather (Serif)' },
{ value: 'inter', label: 'Inter (Sans)' },
{ value: 'atkinson', label: 'Atkinson (Dyslexia-friendly)' },
]
interface ReadingSettingsProps {
onClose: () => void
}
export function ReadingSettings({ onClose }: ReadingSettingsProps) {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const [preferences, setPreferences] = useState<ReadingPreference>(loadPreferences())
// Reload preferences on mount
useEffect(() => {
setPreferences(loadPreferences())
}, [])
const applyPreset = (presetName: string) => {
const preset = getPreset(presetName as any)
setPreferences(preset)
savePreferences(preset)
// Trigger a storage event to notify other components
window.dispatchEvent(new Event('storage'))
}
const handleChange = (key: keyof ReadingPreference, value: any) => {
const updated: ReadingPreference = {
...preferences,
[key]: value,
preset: 'custom' as const
}
setPreferences(updated)
savePreferences(updated)
// Trigger a storage event to notify other components
window.dispatchEvent(new Event('storage'))
}
const content = (
<Box sx={{ p: 3, maxWidth: 400 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">Reading Settings</Typography>
<IconButton size="small" onClick={onClose} aria-label="Close settings">
<Close />
</IconButton>
</Box>
{/* Presets */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Presets</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{['default', 'dyslexia', 'highContrast', 'minimal'].map((preset) => (
<Button
key={preset}
variant={preferences.preset === preset ? 'contained' : 'outlined'}
onClick={() => applyPreset(preset)}
size="small"
sx={{ textTransform: 'capitalize' }}
>
{preset === 'highContrast' ? 'High Contrast' : preset}
</Button>
))}
</Box>
</Box>
{/* Font */}
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Font</InputLabel>
<Select
value={preferences.fontFamily}
label="Font"
onChange={(e) => handleChange('fontFamily', e.target.value)}
>
{FONTS.map((font) => (
<MenuItem key={font.value} value={font.value}>
{font.label}
</MenuItem>
))}
</Select>
</FormControl>
{/* Font Size */}
<Box sx={{ mb: 2 }}>
<Typography variant="body2">Size: {preferences.fontSize}px</Typography>
<Slider
value={preferences.fontSize}
onChange={(_, value) => handleChange('fontSize', value)}
min={12}
max={32}
step={1}
marks={[
{ value: 12, label: '12' },
{ value: 22, label: '22' },
{ value: 32, label: '32' },
]}
/>
</Box>
{/* Line Height */}
<Box sx={{ mb: 2 }}>
<Typography variant="body2">Line Height: {preferences.lineHeight.toFixed(1)}x</Typography>
<Slider
value={preferences.lineHeight}
onChange={(_, value) => handleChange('lineHeight', value)}
min={1.4}
max={2.2}
step={0.1}
marks={[
{ value: 1.4, label: '1.4' },
{ value: 1.8, label: '1.8' },
{ value: 2.2, label: '2.2' },
]}
/>
</Box>
{/* Background Color */}
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Background</InputLabel>
<Select
value={preferences.backgroundColor}
label="Background"
onChange={(e) => handleChange('backgroundColor', e.target.value)}
>
<MenuItem value="#faf8f3">Warm</MenuItem>
<MenuItem value="#ffffff">White</MenuItem>
<MenuItem value="#f5f5f5">Light Gray</MenuItem>
<MenuItem value="#1a1a1a">Dark</MenuItem>
</Select>
</FormControl>
</Box>
)
if (isMobile) {
return (
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
maxHeight: '80vh',
backgroundColor: 'white',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
zIndex: 100,
overflow: 'auto',
boxShadow: '0 -4px 20px rgba(0,0,0,0.1)',
}}
>
{content}
</Box>
)
}
return (
<Paper
sx={{
position: 'fixed',
right: 0,
top: 0,
bottom: 0,
width: 400,
zIndex: 100,
borderRadius: 0,
overflow: 'auto',
boxShadow: '-4px 0 20px rgba(0,0,0,0.1)',
}}
>
{content}
</Paper>
)
}