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:
16
__tests__/lib/reading-preferences.test.ts
Normal file
16
__tests__/lib/reading-preferences.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
176
components/bible/reading-view.tsx
Normal file
176
components/bible/reading-view.tsx
Normal 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
108
lib/reading-preferences.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user