From 13d23d979fc5cb6c507eaf976bfa32ee3d2af786 Mon Sep 17 00:00:00 2001 From: Andrei Date: Tue, 11 Nov 2025 19:35:58 +0000 Subject: [PATCH] feat: implement responsive ReadingView with preference support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/lib/reading-preferences.test.ts | 16 ++ components/bible/reading-view.tsx | 176 ++++++++++++++++++++++ lib/reading-preferences.ts | 108 +++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 __tests__/lib/reading-preferences.test.ts create mode 100644 components/bible/reading-view.tsx create mode 100644 lib/reading-preferences.ts diff --git a/__tests__/lib/reading-preferences.test.ts b/__tests__/lib/reading-preferences.test.ts new file mode 100644 index 0000000..9a4146f --- /dev/null +++ b/__tests__/lib/reading-preferences.test.ts @@ -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') + }) +}) diff --git a/components/bible/reading-view.tsx b/components/bible/reading-view.tsx new file mode 100644 index 0000000..ef48268 --- /dev/null +++ b/components/bible/reading-view.tsx @@ -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 ( + + Loading chapter... + + ) + } + + return ( + { + 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) && ( + + + {chapter.bookName} {chapter.chapter} + + + )} + + {/* Main Text Area */} + + {chapter.verses.map((verse) => ( + { + 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' + }} + > + + {verse.verseNum} + + {verse.text}{' '} + + ))} + + + {/* Navigation Footer */} + {(showControls || !isMobile) && ( + + + + + + + Chapter {chapter.chapter} + + + + + + + + + + + )} + + ) +} diff --git a/lib/reading-preferences.ts b/lib/reading-preferences.ts new file mode 100644 index 0000000..a6ad0f3 --- /dev/null +++ b/lib/reading-preferences.ts @@ -0,0 +1,108 @@ +import { ReadingPreference } from '@/types' + +const PRESETS: Record = { + 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 { + 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 = { + 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 = { + narrow: 'max(1rem, 5%)', + normal: 'max(2rem, 10%)', + wide: 'max(4rem, 15%)', + } + return margins[margin] || margins.normal +}