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:
@@ -1,66 +1,10 @@
|
|||||||
import { Suspense } from 'react'
|
import { BibleReaderApp } from '@/components/bible/bible-reader-app'
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import BibleReader from './reader'
|
|
||||||
import { prisma } from '@/lib/db'
|
|
||||||
|
|
||||||
interface PageProps {
|
export const metadata = {
|
||||||
searchParams: Promise<{
|
title: 'Read Bible',
|
||||||
version?: string
|
description: 'Modern Bible reader with offline support'
|
||||||
book?: string
|
|
||||||
chapter?: string
|
|
||||||
verse?: string
|
|
||||||
}>
|
|
||||||
params: Promise<{
|
|
||||||
locale: string
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert UUIDs to SEO-friendly slugs
|
export default function BiblePage() {
|
||||||
async function convertToSeoUrl(versionId: string, bookId: string, chapter: string, locale: string) {
|
return <BibleReaderApp />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
144
components/bible/bible-reader-app.tsx
Normal file
144
components/bible/bible-reader-app.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
components/bible/reading-settings.tsx
Normal file
182
components/bible/reading-settings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user