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 { 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 />
|
||||
}
|
||||
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