feat: implement responsive ReadingView with preference support

Implements Task 3 from Bible Reader 2025 plan:
- Created lib/reading-preferences.ts with 4 presets (default, dyslexia, highContrast, minimal)
- Implemented loadPreferences/savePreferences using localStorage
- Added getCSSVariables for dynamic styling
- Created ReadingView component with full mobile responsiveness
- Touch interaction: tap top third shows header, bottom third toggles controls
- Verse text is clickable with hover effects
- Navigation controls (prev/next chapter, settings button)
- Created test file for preferences

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-11 19:35:58 +00:00
parent 4287a74805
commit 13d23d979f
3 changed files with 300 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
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 correctly', () => {
const preset = getPreset('dyslexia')
const vars = getCSSVariables(preset)
expect(vars['--font-size']).toBe('18px')
expect(vars['--letter-spacing']).toBe('0.08em')
})
})

View File

@@ -0,0 +1,176 @@
'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>
)
}

108
lib/reading-preferences.ts Normal file
View File

@@ -0,0 +1,108 @@
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
}