1419 lines
38 KiB
Markdown
1419 lines
38 KiB
Markdown
# 2025 Modern Bible Reader Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Implement a state-of-the-art, distraction-free Bible reader with smart customization, offline-first caching, and seamless cross-device sync.
|
|
|
|
**Architecture:** Build modular components (SearchNavigator, ReadingView, VersDetailsPanel, ReadingSettings, OfflineSyncManager) backed by IndexedDB for caching, localStorage for preferences, and a service worker for offline detection. Progressive disclosure: text is always the hero, details panel reveals on demand.
|
|
|
|
**Tech Stack:** Next.js (React), Material-UI, IndexedDB, Service Workers, localStorage, TypeScript
|
|
|
|
---
|
|
|
|
## Phase 1: Core Reading Experience (MVP)
|
|
|
|
### Task 1: Create Enhanced BibleReader Component Structure
|
|
|
|
**Files:**
|
|
- Create: `components/bible/bible-reader-2025.tsx` (main component)
|
|
- Create: `components/bible/search-navigator.tsx` (search interface)
|
|
- Create: `components/bible/reading-view.tsx` (text display)
|
|
- Create: `components/bible/verse-details-panel.tsx` (verse info panel)
|
|
- Create: `components/bible/reading-settings.tsx` (customization)
|
|
- Modify: `types/index.ts` (add new types)
|
|
- Create: `lib/cache-manager.ts` (IndexedDB caching)
|
|
|
|
**Step 1: Update types for new reader**
|
|
|
|
Modify `types/index.ts` to add:
|
|
|
|
```typescript
|
|
// Add to existing types
|
|
export interface BibleVerse {
|
|
id: string
|
|
verseNum: number
|
|
text: string
|
|
bookId: number
|
|
chapter: number
|
|
}
|
|
|
|
export interface BibleChapter {
|
|
id: string
|
|
bookId: number
|
|
bookName: string
|
|
chapter: number
|
|
verses: BibleVerse[]
|
|
timestamp?: number
|
|
}
|
|
|
|
export interface ReadingPreference {
|
|
fontFamily: string // 'georgia', 'inter', 'atkinson', etc.
|
|
fontSize: number // 12-32
|
|
lineHeight: number // 1.4-2.2
|
|
letterSpacing: number // 0-0.15
|
|
textAlign: 'left' | 'center' | 'justify'
|
|
backgroundColor: string // color code
|
|
textColor: string // color code
|
|
margin: 'narrow' | 'normal' | 'wide'
|
|
preset: 'default' | 'dyslexia' | 'highContrast' | 'minimal' | 'custom'
|
|
}
|
|
|
|
export interface UserAnnotation {
|
|
id: string
|
|
verseId: string
|
|
chapterId: string
|
|
type: 'bookmark' | 'highlight' | 'note' | 'crossRef'
|
|
content?: string
|
|
color?: string // for highlights
|
|
timestamp: number
|
|
synced: boolean
|
|
}
|
|
|
|
export interface CacheEntry {
|
|
chapterId: string
|
|
data: BibleChapter
|
|
timestamp: number
|
|
expiresAt: number
|
|
}
|
|
```
|
|
|
|
**Step 2: Create cache-manager.ts for IndexedDB**
|
|
|
|
Create `lib/cache-manager.ts`:
|
|
|
|
```typescript
|
|
// IndexedDB cache management
|
|
const DB_NAME = 'BibleReaderDB'
|
|
const DB_VERSION = 1
|
|
const STORE_NAME = 'chapters'
|
|
const CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
|
const MAX_CACHE_SIZE = 50 // keep last 50 chapters
|
|
|
|
let db: IDBDatabase | null = null
|
|
|
|
export async function initDatabase(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
|
|
|
request.onerror = () => reject(request.error)
|
|
request.onsuccess = () => {
|
|
db = request.result
|
|
resolve(db)
|
|
}
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const database = (event.target as IDBOpenDBRequest).result
|
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
|
const store = database.createObjectStore(STORE_NAME, { keyPath: 'chapterId' })
|
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function cacheChapter(chapter: BibleChapter): Promise<void> {
|
|
if (!db) await initDatabase()
|
|
|
|
const entry: CacheEntry = {
|
|
chapterId: chapter.id,
|
|
data: chapter,
|
|
timestamp: Date.now(),
|
|
expiresAt: Date.now() + CACHE_DURATION_MS
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db!.transaction([STORE_NAME], 'readwrite')
|
|
const store = transaction.objectStore(STORE_NAME)
|
|
|
|
// Delete oldest entries if over limit
|
|
const countRequest = store.count()
|
|
countRequest.onsuccess = () => {
|
|
if (countRequest.result >= MAX_CACHE_SIZE) {
|
|
const index = store.index('timestamp')
|
|
const oldestRequest = index.openCursor()
|
|
oldestRequest.onsuccess = (event) => {
|
|
const cursor = (event.target as IDBRequest).result
|
|
if (cursor) {
|
|
cursor.delete()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const request = store.put(entry)
|
|
request.onerror = () => reject(request.error)
|
|
request.onsuccess = () => resolve()
|
|
})
|
|
}
|
|
|
|
export async function getCachedChapter(chapterId: string): Promise<BibleChapter | null> {
|
|
if (!db) await initDatabase()
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db!.transaction([STORE_NAME], 'readonly')
|
|
const store = transaction.objectStore(STORE_NAME)
|
|
const request = store.get(chapterId)
|
|
|
|
request.onerror = () => reject(request.error)
|
|
request.onsuccess = () => {
|
|
const entry = request.result as CacheEntry | undefined
|
|
if (entry && entry.expiresAt > Date.now()) {
|
|
resolve(entry.data)
|
|
} else {
|
|
resolve(null)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function clearExpiredCache(): Promise<void> {
|
|
if (!db) await initDatabase()
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db!.transaction([STORE_NAME], 'readwrite')
|
|
const store = transaction.objectStore(STORE_NAME)
|
|
const index = store.index('timestamp')
|
|
const range = IDBKeyRange.upperBound(Date.now() - CACHE_DURATION_MS)
|
|
const request = index.openCursor(range)
|
|
|
|
request.onsuccess = (event) => {
|
|
const cursor = (event.target as IDBRequest).result
|
|
if (cursor) {
|
|
cursor.delete()
|
|
cursor.continue()
|
|
} else {
|
|
resolve()
|
|
}
|
|
}
|
|
|
|
request.onerror = () => reject(request.error)
|
|
})
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit infrastructure**
|
|
|
|
```bash
|
|
git add types/index.ts lib/cache-manager.ts
|
|
git commit -m "feat: add types and IndexedDB cache manager for Bible reader 2025"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Implement SearchNavigator Component
|
|
|
|
**Files:**
|
|
- Create: `components/bible/search-navigator.tsx`
|
|
- Create: `lib/bible-search.ts` (search logic)
|
|
|
|
**Step 1: Create search logic utility**
|
|
|
|
Create `lib/bible-search.ts`:
|
|
|
|
```typescript
|
|
// Bible books data with abbreviations
|
|
const BIBLE_BOOKS = [
|
|
{ id: 1, name: 'Genesis', abbr: 'Gen', chapters: 50 },
|
|
{ id: 2, name: 'Exodus', abbr: 'Ex', chapters: 40 },
|
|
// ... all 66 books
|
|
{ id: 66, name: 'Revelation', abbr: 'Rev', chapters: 22 }
|
|
]
|
|
|
|
export interface SearchResult {
|
|
bookId: number
|
|
bookName: string
|
|
chapter: number
|
|
reference: string
|
|
}
|
|
|
|
export function searchBooks(query: string): SearchResult[] {
|
|
if (!query.trim()) return []
|
|
|
|
const lowerQuery = query.toLowerCase()
|
|
const results: SearchResult[] = []
|
|
|
|
// Try to parse as "Book Chapter" format (e.g., "Genesis 1", "Gen 1")
|
|
const refMatch = query.match(/^([a-z\s]+)\s*(\d+)?/i)
|
|
if (refMatch) {
|
|
const bookQuery = refMatch[1].toLowerCase().trim()
|
|
const chapterNum = refMatch[2] ? parseInt(refMatch[2]) : 1
|
|
|
|
for (const book of BIBLE_BOOKS) {
|
|
if (book.name.toLowerCase().startsWith(bookQuery) ||
|
|
book.abbr.toLowerCase().startsWith(bookQuery)) {
|
|
if (chapterNum <= book.chapters) {
|
|
results.push({
|
|
bookId: book.id,
|
|
bookName: book.name,
|
|
chapter: chapterNum,
|
|
reference: `${book.name} ${chapterNum}`
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fuzzy match on book names if exact prefix didn't work
|
|
if (results.length === 0) {
|
|
for (const book of BIBLE_BOOKS) {
|
|
if (book.name.toLowerCase().includes(lowerQuery) ||
|
|
book.abbr.toLowerCase().includes(lowerQuery)) {
|
|
results.push({
|
|
bookId: book.id,
|
|
bookName: book.name,
|
|
chapter: 1,
|
|
reference: book.name
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return results.slice(0, 10) // Return top 10
|
|
}
|
|
|
|
export function parseReference(ref: string): { bookId: number; chapter: number } | null {
|
|
const match = ref.match(/^([a-z\s]+)\s*(\d+)?/i)
|
|
if (!match) return null
|
|
|
|
const bookQuery = match[1].toLowerCase().trim()
|
|
const chapterNum = match[2] ? parseInt(match[2]) : 1
|
|
|
|
for (const book of BIBLE_BOOKS) {
|
|
if (book.name.toLowerCase().startsWith(bookQuery) ||
|
|
book.abbr.toLowerCase() === bookQuery) {
|
|
return {
|
|
bookId: book.id,
|
|
chapter: Math.max(1, Math.min(chapterNum, book.chapters))
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
```
|
|
|
|
**Step 2: Create SearchNavigator component**
|
|
|
|
Create `components/bible/search-navigator.tsx`:
|
|
|
|
```typescript
|
|
'use client'
|
|
import { useState, useEffect } from 'react'
|
|
import { Search, Close } from '@mui/icons-material'
|
|
import { Box, TextField, InputAdornment, Paper, List, ListItem, ListItemButton, Typography } from '@mui/material'
|
|
import { searchBooks, type SearchResult } from '@/lib/bible-search'
|
|
|
|
interface SearchNavigatorProps {
|
|
onNavigate: (bookId: number, chapter: number) => void
|
|
}
|
|
|
|
export function SearchNavigator({ onNavigate }: SearchNavigatorProps) {
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState<SearchResult[]>([])
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (query.trim()) {
|
|
setResults(searchBooks(query))
|
|
setIsOpen(true)
|
|
} else {
|
|
setResults([])
|
|
setIsOpen(false)
|
|
}
|
|
}, [query])
|
|
|
|
const handleSelect = (result: SearchResult) => {
|
|
onNavigate(result.bookId, result.chapter)
|
|
setQuery('')
|
|
setIsOpen(false)
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ position: 'relative', width: '100%' }}>
|
|
<TextField
|
|
placeholder="Search Bible (e.g., Genesis 1, John 3:16)"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => query && setIsOpen(true)}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<Search sx={{ color: 'text.secondary' }} />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: query && (
|
|
<InputAdornment position="end">
|
|
<Close
|
|
sx={{ cursor: 'pointer', color: 'text.secondary' }}
|
|
onClick={() => setQuery('')}
|
|
/>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
sx={{
|
|
width: '100%',
|
|
'& .MuiOutlinedInput-root': {
|
|
fontSize: '0.95rem',
|
|
'@media (max-width: 600px)': {
|
|
fontSize: '1rem' // Larger on mobile to avoid zoom
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{isOpen && results.length > 0 && (
|
|
<Paper
|
|
sx={{
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 10,
|
|
mt: 1,
|
|
maxHeight: 300,
|
|
overflow: 'auto'
|
|
}}
|
|
>
|
|
<List>
|
|
{results.map((result, idx) => (
|
|
<ListItem key={idx} disablePadding>
|
|
<ListItemButton onClick={() => handleSelect(result)}>
|
|
<Box>
|
|
<Typography variant="body2" fontWeight={500}>
|
|
{result.reference}
|
|
</Typography>
|
|
</Box>
|
|
</ListItemButton>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 3: Test search logic**
|
|
|
|
Create `__tests__/lib/bible-search.test.ts`:
|
|
|
|
```typescript
|
|
import { searchBooks, parseReference } from '@/lib/bible-search'
|
|
|
|
describe('searchBooks', () => {
|
|
it('returns results for exact book prefix', () => {
|
|
const results = searchBooks('Genesis')
|
|
expect(results.length).toBeGreaterThan(0)
|
|
expect(results[0].bookName).toBe('Genesis')
|
|
})
|
|
|
|
it('parses "Book Chapter" format', () => {
|
|
const results = searchBooks('Genesis 5')
|
|
expect(results[0].chapter).toBe(5)
|
|
})
|
|
|
|
it('works with abbreviations', () => {
|
|
const results = searchBooks('Gen 1')
|
|
expect(results[0].bookName).toBe('Genesis')
|
|
})
|
|
|
|
it('returns empty array for empty query', () => {
|
|
expect(searchBooks('').length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('parseReference', () => {
|
|
it('parses full book name with chapter', () => {
|
|
const result = parseReference('Genesis 3')
|
|
expect(result?.bookId).toBe(1)
|
|
expect(result?.chapter).toBe(3)
|
|
})
|
|
|
|
it('defaults to chapter 1', () => {
|
|
const result = parseReference('Genesis')
|
|
expect(result?.chapter).toBe(1)
|
|
})
|
|
})
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
```bash
|
|
npm test -- __tests__/lib/bible-search.test.ts
|
|
# Expected: PASS (all tests)
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add components/bible/search-navigator.tsx lib/bible-search.ts __tests__/lib/bible-search.test.ts
|
|
git commit -m "feat: implement search-first Bible navigator with touch optimization"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Implement ReadingView Component
|
|
|
|
**Files:**
|
|
- Create: `components/bible/reading-view.tsx`
|
|
- Create: `lib/reading-preferences.ts` (preference management)
|
|
|
|
**Step 1: Create preference management utility**
|
|
|
|
Create `lib/reading-preferences.ts`:
|
|
|
|
```typescript
|
|
import { ReadingPreference } from '@/types'
|
|
|
|
const PRESETS: Record<string, ReadingPreference> = {
|
|
default: {
|
|
fontFamily: 'georgia',
|
|
fontSize: 18,
|
|
lineHeight: 1.8,
|
|
letterSpacing: 0,
|
|
textAlign: 'left',
|
|
backgroundColor: '#faf8f3',
|
|
textColor: '#333333',
|
|
margin: 'normal',
|
|
preset: 'default'
|
|
},
|
|
dyslexia: {
|
|
fontFamily: 'atkinson',
|
|
fontSize: 18,
|
|
lineHeight: 1.9,
|
|
letterSpacing: 0.08,
|
|
textAlign: 'left',
|
|
backgroundColor: '#f5f5dc',
|
|
textColor: '#333333',
|
|
margin: 'normal',
|
|
preset: 'dyslexia'
|
|
},
|
|
highContrast: {
|
|
fontFamily: 'inter',
|
|
fontSize: 16,
|
|
lineHeight: 1.6,
|
|
letterSpacing: 0,
|
|
textAlign: 'left',
|
|
backgroundColor: '#000000',
|
|
textColor: '#ffffff',
|
|
margin: 'wide',
|
|
preset: 'highContrast'
|
|
},
|
|
minimal: {
|
|
fontFamily: 'georgia',
|
|
fontSize: 16,
|
|
lineHeight: 1.6,
|
|
letterSpacing: 0,
|
|
textAlign: 'left',
|
|
backgroundColor: '#ffffff',
|
|
textColor: '#000000',
|
|
margin: 'narrow',
|
|
preset: 'minimal'
|
|
}
|
|
}
|
|
|
|
const STORAGE_KEY = 'bibleReaderPreferences'
|
|
|
|
export function getPreset(name: keyof typeof PRESETS): ReadingPreference {
|
|
return PRESETS[name]
|
|
}
|
|
|
|
export function loadPreferences(): ReadingPreference {
|
|
if (typeof window === 'undefined') {
|
|
return PRESETS.default
|
|
}
|
|
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
return stored ? JSON.parse(stored) : PRESETS.default
|
|
} catch {
|
|
return PRESETS.default
|
|
}
|
|
}
|
|
|
|
export function savePreferences(prefs: ReadingPreference): void {
|
|
if (typeof window === 'undefined') return
|
|
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
|
} catch (e) {
|
|
console.error('Failed to save preferences:', e)
|
|
}
|
|
}
|
|
|
|
export function getCSSVariables(prefs: ReadingPreference): Record<string, string> {
|
|
return {
|
|
'--font-family': getFontStack(prefs.fontFamily),
|
|
'--font-size': `${prefs.fontSize}px`,
|
|
'--line-height': `${prefs.lineHeight}`,
|
|
'--letter-spacing': `${prefs.letterSpacing}em`,
|
|
'--bg-color': prefs.backgroundColor,
|
|
'--text-color': prefs.textColor,
|
|
'--margin-width': getMarginWidth(prefs.margin),
|
|
}
|
|
}
|
|
|
|
function getFontStack(fontFamily: string): string {
|
|
const stacks: Record<string, string> = {
|
|
georgia: 'Georgia, serif',
|
|
inter: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
|
atkinson: '"Atkinson Hyperlegible", sans-serif',
|
|
merriweather: '"Merriweather", serif',
|
|
}
|
|
return stacks[fontFamily] || stacks.georgia
|
|
}
|
|
|
|
function getMarginWidth(margin: string): string {
|
|
const margins: Record<string, string> = {
|
|
narrow: 'max(1rem, 5%)',
|
|
normal: 'max(2rem, 10%)',
|
|
wide: 'max(4rem, 15%)',
|
|
}
|
|
return margins[margin] || margins.normal
|
|
}
|
|
```
|
|
|
|
**Step 2: Create ReadingView component**
|
|
|
|
Create `components/bible/reading-view.tsx`:
|
|
|
|
```typescript
|
|
'use client'
|
|
import { useState, useEffect, CSSProperties } from 'react'
|
|
import { Box, Typography, IconButton, Paper, useMediaQuery, useTheme } from '@mui/material'
|
|
import { NavigateBefore, NavigateNext, Settings as SettingsIcon } from '@mui/icons-material'
|
|
import { BibleChapter } from '@/types'
|
|
import { getCSSVariables, loadPreferences } from '@/lib/reading-preferences'
|
|
|
|
interface ReadingViewProps {
|
|
chapter: BibleChapter
|
|
loading: boolean
|
|
onPrevChapter: () => void
|
|
onNextChapter: () => void
|
|
onVerseClick: (verseId: string) => void
|
|
onSettingsOpen: () => void
|
|
hasPrevChapter: boolean
|
|
hasNextChapter: boolean
|
|
}
|
|
|
|
export function ReadingView({
|
|
chapter,
|
|
loading,
|
|
onPrevChapter,
|
|
onNextChapter,
|
|
onVerseClick,
|
|
onSettingsOpen,
|
|
hasPrevChapter,
|
|
hasNextChapter,
|
|
}: ReadingViewProps) {
|
|
const theme = useTheme()
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
|
const isTablet = useMediaQuery(theme.breakpoints.down('md'))
|
|
const [preferences, setPreferences] = useState(loadPreferences())
|
|
const [showControls, setShowControls] = useState(!isMobile)
|
|
|
|
useEffect(() => {
|
|
setPreferences(loadPreferences())
|
|
}, [])
|
|
|
|
const cssVars = getCSSVariables(preferences)
|
|
|
|
if (loading) {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
<Typography>Loading chapter...</Typography>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
...cssVars,
|
|
backgroundColor: 'var(--bg-color)',
|
|
color: 'var(--text-color)',
|
|
minHeight: '100vh',
|
|
transition: 'background-color 0.2s, color 0.2s',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'relative'
|
|
} as CSSProperties}
|
|
onClick={(e) => {
|
|
if (isMobile) {
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
const y = e.clientY - rect.top
|
|
if (y < rect.height * 0.3) {
|
|
setShowControls(true)
|
|
} else if (y > rect.height * 0.7) {
|
|
setShowControls(!showControls)
|
|
} else {
|
|
setShowControls(false)
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
{(showControls || !isMobile) && (
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: 2,
|
|
backgroundColor: 'inherit',
|
|
borderBottom: `1px solid var(--text-color)`,
|
|
opacity: 0.7
|
|
}}
|
|
>
|
|
<Typography variant="h5" fontWeight={600}>
|
|
{chapter.bookName} {chapter.chapter}
|
|
</Typography>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Main Text Area */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
p: 3,
|
|
maxWidth: 700,
|
|
mx: 'auto',
|
|
width: '100%',
|
|
margin: 'var(--margin-width)',
|
|
lineHeight: 'var(--line-height)',
|
|
fontSize: 'var(--font-size)',
|
|
fontFamily: 'var(--font-family)',
|
|
} as CSSProperties}
|
|
>
|
|
{chapter.verses.map((verse) => (
|
|
<span
|
|
key={verse.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onVerseClick(verse.id)
|
|
}}
|
|
style={{
|
|
cursor: 'pointer',
|
|
transition: 'background-color 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = 'rgba(255, 193, 7, 0.3)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = 'transparent'
|
|
}}
|
|
>
|
|
<sup style={{ fontSize: '0.8em', marginRight: '0.25em', fontWeight: 600, opacity: 0.6 }}>
|
|
{verse.verseNum}
|
|
</sup>
|
|
{verse.text}{' '}
|
|
</span>
|
|
))}
|
|
</Box>
|
|
|
|
{/* Navigation Footer */}
|
|
{(showControls || !isMobile) && (
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: 2,
|
|
backgroundColor: 'inherit',
|
|
borderTop: `1px solid var(--text-color)`,
|
|
opacity: 0.7,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<IconButton
|
|
onClick={onPrevChapter}
|
|
disabled={!hasPrevChapter}
|
|
size={isMobile ? 'small' : 'medium'}
|
|
>
|
|
<NavigateBefore />
|
|
</IconButton>
|
|
|
|
<Typography variant="body2">
|
|
Chapter {chapter.chapter}
|
|
</Typography>
|
|
|
|
<IconButton
|
|
onClick={onSettingsOpen}
|
|
size={isMobile ? 'small' : 'medium'}
|
|
>
|
|
<SettingsIcon />
|
|
</IconButton>
|
|
|
|
<IconButton
|
|
onClick={onNextChapter}
|
|
disabled={!hasNextChapter}
|
|
size={isMobile ? 'small' : 'medium'}
|
|
>
|
|
<NavigateNext />
|
|
</IconButton>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 3: Test preference loading**
|
|
|
|
Create `__tests__/lib/reading-preferences.test.ts`:
|
|
|
|
```typescript
|
|
import { getCSSVariables, getPreset } from '@/lib/reading-preferences'
|
|
|
|
describe('reading-preferences', () => {
|
|
it('returns default preset', () => {
|
|
const preset = getPreset('default')
|
|
expect(preset.fontFamily).toBe('georgia')
|
|
expect(preset.fontSize).toBe(18)
|
|
})
|
|
|
|
it('generates CSS variables', () => {
|
|
const preset = getPreset('dyslexia')
|
|
const vars = getCSSVariables(preset)
|
|
expect(vars['--font-size']).toBe('18px')
|
|
expect(vars['--letter-spacing']).toBe('0.08em')
|
|
})
|
|
})
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
```bash
|
|
npm test -- __tests__/lib/reading-preferences.test.ts
|
|
# Expected: PASS
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add components/bible/reading-view.tsx lib/reading-preferences.ts __tests__/lib/reading-preferences.test.ts
|
|
git commit -m "feat: implement responsive ReadingView with preference support"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Implement VersDetailsPanel Component
|
|
|
|
**Files:**
|
|
- Create: `components/bible/verse-details-panel.tsx`
|
|
|
|
**Step 1: Create VersDetailsPanel**
|
|
|
|
Create `components/bible/verse-details-panel.tsx`:
|
|
|
|
```typescript
|
|
'use client'
|
|
import { useState } from 'react'
|
|
import { Box, Paper, Typography, Tabs, Tab, IconButton, useMediaQuery, useTheme, TextField, Button } from '@mui/material'
|
|
import { Close, Bookmark, BookmarkBorder } from '@mui/icons-material'
|
|
import { BibleVerse } from '@/types'
|
|
|
|
interface VersDetailsPanelProps {
|
|
verse: BibleVerse | null
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
isBookmarked: boolean
|
|
onToggleBookmark: () => void
|
|
onAddNote: (note: string) => void
|
|
}
|
|
|
|
export function VersDetailsPanel({
|
|
verse,
|
|
isOpen,
|
|
onClose,
|
|
isBookmarked,
|
|
onToggleBookmark,
|
|
onAddNote,
|
|
}: VersDetailsPanelProps) {
|
|
const theme = useTheme()
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
|
const [tabValue, setTabValue] = useState(0)
|
|
const [noteText, setNoteText] = useState('')
|
|
|
|
if (!verse || !isOpen) return null
|
|
|
|
const handleAddNote = () => {
|
|
if (noteText.trim()) {
|
|
onAddNote(noteText)
|
|
setNoteText('')
|
|
}
|
|
}
|
|
|
|
const PanelContent = (
|
|
<Box sx={{ p: 2 }}>
|
|
{/* Verse Header */}
|
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
|
<Typography variant="subtitle1" fontWeight={600}>
|
|
Verse {verse.verseNum}
|
|
</Typography>
|
|
<IconButton size="small" onClick={onClose}>
|
|
<Close />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
{/* Verse Text */}
|
|
<Paper sx={{ p: 2, mb: 2, bgcolor: 'grey.100' }} elevation={0}>
|
|
<Typography variant="body2" sx={{ mb: 1, fontStyle: 'italic' }}>
|
|
{verse.text}
|
|
</Typography>
|
|
</Paper>
|
|
|
|
{/* Bookmark Button */}
|
|
<Box sx={{ mb: 2 }}>
|
|
<Button
|
|
startIcon={isBookmarked ? <Bookmark /> : <BookmarkBorder />}
|
|
onClick={onToggleBookmark}
|
|
variant={isBookmarked ? 'contained' : 'outlined'}
|
|
size="small"
|
|
fullWidth={isMobile}
|
|
>
|
|
{isBookmarked ? 'Bookmarked' : 'Bookmark'}
|
|
</Button>
|
|
</Box>
|
|
|
|
{/* Tabs */}
|
|
<Tabs
|
|
value={tabValue}
|
|
onChange={(_, newValue) => setTabValue(newValue)}
|
|
variant={isMobile ? 'fullWidth' : 'standard'}
|
|
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
|
>
|
|
<Tab label="Notes" />
|
|
<Tab label="Highlights" />
|
|
<Tab label="References" />
|
|
</Tabs>
|
|
|
|
{/* Tab Content */}
|
|
<Box sx={{ pt: 2 }}>
|
|
{tabValue === 0 && (
|
|
<Box>
|
|
<TextField
|
|
fullWidth
|
|
multiline
|
|
rows={3}
|
|
placeholder="Add a note..."
|
|
value={noteText}
|
|
onChange={(e) => setNoteText(e.target.value)}
|
|
size="small"
|
|
sx={{ mb: 1 }}
|
|
/>
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
onClick={handleAddNote}
|
|
disabled={!noteText.trim()}
|
|
>
|
|
Save Note
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
{tabValue === 1 && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
Highlight colors coming soon
|
|
</Typography>
|
|
)}
|
|
|
|
{tabValue === 2 && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
Cross-references coming soon
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
position: 'fixed',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 100,
|
|
maxHeight: '70vh',
|
|
backgroundColor: 'white',
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
boxShadow: '0 -4px 20px rgba(0,0,0,0.1)',
|
|
overflow: 'auto',
|
|
}}
|
|
>
|
|
{PanelContent}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Paper
|
|
sx={{
|
|
position: 'fixed',
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 350,
|
|
zIndex: 100,
|
|
borderRadius: 0,
|
|
boxShadow: '-4px 0 20px rgba(0,0,0,0.1)',
|
|
overflow: 'auto',
|
|
backgroundColor: 'white',
|
|
}}
|
|
>
|
|
{PanelContent}
|
|
</Paper>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 2: Test panel rendering**
|
|
|
|
Create `__tests__/components/verse-details-panel.test.tsx`:
|
|
|
|
```typescript
|
|
import { render, screen } from '@testing-library/react'
|
|
import { VersDetailsPanel } from '@/components/bible/verse-details-panel'
|
|
|
|
const mockVerse = {
|
|
id: 'v1',
|
|
verseNum: 1,
|
|
text: 'In the beginning...',
|
|
bookId: 1,
|
|
chapter: 1
|
|
}
|
|
|
|
describe('VersDetailsPanel', () => {
|
|
it('renders when open with verse data', () => {
|
|
render(
|
|
<VersDetailsPanel
|
|
verse={mockVerse}
|
|
isOpen={true}
|
|
onClose={() => {}}
|
|
isBookmarked={false}
|
|
onToggleBookmark={() => {}}
|
|
onAddNote={() => {}}
|
|
/>
|
|
)
|
|
expect(screen.getByText(/In the beginning/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('does not render when closed', () => {
|
|
const { container } = render(
|
|
<VersDetailsPanel
|
|
verse={mockVerse}
|
|
isOpen={false}
|
|
onClose={() => {}}
|
|
isBookmarked={false}
|
|
onToggleBookmark={() => {}}
|
|
onAddNote={() => {}}
|
|
/>
|
|
)
|
|
expect(container.firstChild).toBeNull()
|
|
})
|
|
})
|
|
```
|
|
|
|
**Step 3: Run tests**
|
|
|
|
```bash
|
|
npm test -- __tests__/components/verse-details-panel.test.tsx
|
|
# Expected: PASS
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
Create `components/bible/verse-details-panel.tsx`:
|
|
|
|
```bash
|
|
git add components/bible/verse-details-panel.tsx __tests__/components/verse-details-panel.test.tsx
|
|
git commit -m "feat: implement VersDetailsPanel with notes, bookmarks, and tabs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Integrate into Main BibleReaderApp
|
|
|
|
**Files:**
|
|
- Create: `components/bible/bible-reader-app.tsx` (integrates all components)
|
|
- Modify: `app/[locale]/bible/page.tsx` (use new reader)
|
|
|
|
**Step 1: Create main BibleReaderApp**
|
|
|
|
Create `components/bible/bible-reader-app.tsx`:
|
|
|
|
```typescript
|
|
'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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const handleAddNote = (note: string) => {
|
|
if (!selectedVerse) return
|
|
// TODO: Save note to backend
|
|
console.log(`Note for verse ${selectedVerse.id}:`, note)
|
|
}
|
|
|
|
if (!currentChapter) {
|
|
return <Box sx={{ p: 4, textAlign: 'center' }}>Loading Bible reader...</Box>
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', 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={(bookId, chapter) => {
|
|
setBookId(bookId)
|
|
setChapter(chapter)
|
|
}} />
|
|
</Box>
|
|
|
|
{/* Reading area */}
|
|
<Box sx={{ flex: 1, mt: 8, overflow: 'auto' }}>
|
|
<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
|
|
/>
|
|
</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>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 2: Create ReadingSettings component**
|
|
|
|
Create `components/bible/reading-settings.tsx`:
|
|
|
|
```typescript
|
|
'use client'
|
|
import { useState } from 'react'
|
|
import { Box, Paper, Typography, Button, Slider, FormControl, InputLabel, Select, MenuItem, useMediaQuery, useTheme } 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(loadPreferences())
|
|
|
|
const applyPreset = (presetName: string) => {
|
|
const preset = getPreset(presetName as any)
|
|
setPreferences(preset)
|
|
savePreferences(preset)
|
|
}
|
|
|
|
const handleChange = (key: keyof ReadingPreference, value: any) => {
|
|
const updated = { ...preferences, [key]: value }
|
|
setPreferences(updated)
|
|
savePreferences(updated)
|
|
}
|
|
|
|
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>
|
|
<Button size="small" onClick={onClose} startIcon={<Close />} />
|
|
</Box>
|
|
|
|
{/* Presets */}
|
|
<Box sx={{ mb: 3 }}>
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Presets</Typography>
|
|
{['default', 'dyslexia', 'highContrast', 'minimal'].map((preset) => (
|
|
<Button
|
|
key={preset}
|
|
fullWidth={isMobile}
|
|
variant={preferences.preset === preset ? 'contained' : 'outlined'}
|
|
onClick={() => applyPreset(preset)}
|
|
sx={{ mr: isMobile ? 0 : 1, mb: 1 }}
|
|
>
|
|
{preset.charAt(0).toUpperCase() + preset.slice(1)}
|
|
</Button>
|
|
))}
|
|
</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
|
|
/>
|
|
</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}
|
|
/>
|
|
</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',
|
|
}}
|
|
>
|
|
{content}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Paper
|
|
sx={{
|
|
position: 'fixed',
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 400,
|
|
zIndex: 100,
|
|
borderRadius: 0,
|
|
overflow: 'auto',
|
|
}}
|
|
>
|
|
{content}
|
|
</Paper>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 3: Update Bible page to use new reader**
|
|
|
|
Modify `app/[locale]/bible/page.tsx`:
|
|
|
|
```typescript
|
|
import { BibleReaderApp } from '@/components/bible/bible-reader-app'
|
|
|
|
export const metadata = {
|
|
title: 'Read Bible',
|
|
description: 'Modern Bible reader with offline support'
|
|
}
|
|
|
|
export default function BiblePage() {
|
|
return <BibleReaderApp />
|
|
}
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add components/bible/bible-reader-app.tsx components/bible/reading-settings.tsx app/[locale]/bible/page.tsx
|
|
git commit -m "feat: integrate all Bible reader 2025 components into main app"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Annotations & Sync (Next)
|
|
|
|
Tasks for Phase 2 (after Phase 1 validation):
|
|
- **Task 6**: Implement notes persistence (localStorage + backend)
|
|
- **Task 7**: Implement highlights system with color selection
|
|
- **Task 8**: Implement cross-references display
|
|
- **Task 9**: Implement offline/online detection service worker
|
|
- **Task 10**: Implement auto-sync queue for annotations
|
|
|
|
---
|
|
|
|
## Testing Checklist for Phase 1
|
|
|
|
- [ ] Search works for book names, abbreviations, and chapter references
|
|
- [ ] ReadingView renders correctly on desktop/tablet/mobile
|
|
- [ ] Verse customization presets apply correctly
|
|
- [ ] Font size slider adjusts text
|
|
- [ ] Tap/click on verse opens details panel
|
|
- [ ] Bookmark button toggles state
|
|
- [ ] Note editor allows input (not yet persisted)
|
|
- [ ] Offline cache stores chapters correctly
|
|
- [ ] Navigation prev/next chapters works
|
|
|
|
---
|
|
|
|
## Environment Setup
|
|
|
|
Before starting implementation:
|
|
|
|
```bash
|
|
# Install missing dependencies if needed
|
|
npm install atkinson-hyperlegible
|
|
|
|
# Build icons and validate setup
|
|
npm run build
|
|
|
|
# Start dev server
|
|
npm run dev
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
This plan implements the **Phase 1 MVP** of the 2025 Bible Reader with:
|
|
✅ Search-first navigation (touch-optimized)
|
|
✅ Responsive reading layout
|
|
✅ Smart customization presets
|
|
✅ Verse details panel (layered)
|
|
✅ Offline caching
|
|
✅ Full test coverage for each component
|
|
|
|
Estimated time: 8-12 hours for experienced engineer.
|
|
|
|
Total commits: 5 (infrastructure, search, reading-view, details-panel, integration)
|