feat: implement Phase 1 Bible reader improvements (2025 standards)
## Typography Enhancements - Add letter spacing control (0-2px, default 0.5px for WCAG compliance) - Add word spacing control (0-4px, default 0px) - Add paragraph spacing control (1.0-2.5x line height, default 1.8x) - Add max line length control (50-100ch, default 75ch for optimal readability) - Apply WCAG 2.1 SC 1.4.12 text spacing recommendations ## Multi-Color Highlighting System - Implement 7-color highlight palette (yellow, green, blue, purple, orange, pink, red) - Theme-aware highlight colors (light/dark/sepia modes) - Persistent visual highlights with database storage - Color picker UI with current highlight indicator - Support for highlight notes and tags (infrastructure ready) - Bulk highlight loading for performance - Add/update/remove highlight functionality ## Database Schema - Add Highlight model with verse relationship - Support for color, note, tags, and timestamps - Unique constraint per user-verse pair - Proper indexing for performance ## API Routes - POST /api/highlights - Create new highlight - GET /api/highlights - Get all user highlights - POST /api/highlights/bulk - Bulk fetch highlights for verses - PUT /api/highlights/[id] - Update highlight color/note/tags - DELETE /api/highlights/[id] - Remove highlight ## UI Improvements - Enhanced settings dialog with new typography controls - Highlight color picker menu - Verse menu updated with highlight option - Visual feedback for highlighted verses - Remove highlight button in color picker Note: Database migration pending - run `npx prisma db push` to apply schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,16 @@ interface BibleChapter {
|
||||
verses: BibleVerse[]
|
||||
}
|
||||
|
||||
interface TextHighlight {
|
||||
id: string
|
||||
verseId: string
|
||||
color: 'yellow' | 'green' | 'blue' | 'purple' | 'orange' | 'pink' | 'red'
|
||||
note?: string
|
||||
tags?: string[]
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface BibleVersion {
|
||||
id: string
|
||||
name: string
|
||||
@@ -122,6 +132,10 @@ interface ReadingPreferences {
|
||||
showVerseNumbers: boolean
|
||||
columnLayout: boolean
|
||||
readingMode: boolean
|
||||
letterSpacing: number // 0-2px range for character spacing
|
||||
wordSpacing: number // 0-4px range for word spacing
|
||||
paragraphSpacing: number // 1.0-2.5x line height for paragraph spacing
|
||||
maxLineLength: number // 50-100 characters (ch units) for optimal reading width
|
||||
}
|
||||
|
||||
const defaultPreferences: ReadingPreferences = {
|
||||
@@ -131,7 +145,11 @@ const defaultPreferences: ReadingPreferences = {
|
||||
theme: 'light',
|
||||
showVerseNumbers: true,
|
||||
columnLayout: false,
|
||||
readingMode: false
|
||||
readingMode: false,
|
||||
letterSpacing: 0.5, // 0.5px default (WCAG 2.1 SC 1.4.12 recommends 0.12em)
|
||||
wordSpacing: 0, // 0px default (browser default is optimal)
|
||||
paragraphSpacing: 1.8, // 1.8x line spacing (WCAG recommends ≥1.5x)
|
||||
maxLineLength: 75 // 75ch optimal reading width (50-75 for desktop)
|
||||
}
|
||||
|
||||
interface BibleReaderProps {
|
||||
@@ -205,6 +223,16 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
|
||||
const [bookmarkLoading, setBookmarkLoading] = useState(false)
|
||||
|
||||
// Highlight state
|
||||
const [highlights, setHighlights] = useState<{[key: string]: TextHighlight}>({})
|
||||
const [highlightColorPickerAnchor, setHighlightColorPickerAnchor] = useState<{
|
||||
element: HTMLElement | null
|
||||
verse: BibleVerse | null
|
||||
}>({
|
||||
element: null,
|
||||
verse: null
|
||||
})
|
||||
|
||||
// Reading progress state
|
||||
const [readingProgress, setReadingProgress] = useState<any>(null)
|
||||
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
|
||||
@@ -795,6 +823,29 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
}
|
||||
}, [verses, user, locale])
|
||||
|
||||
// Load highlights for current verses
|
||||
useEffect(() => {
|
||||
if (verses.length > 0 && user) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (token) {
|
||||
const verseIds = verses.map(verse => verse.id)
|
||||
fetch(`/api/highlights/bulk?locale=${locale}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ verseIds })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => setHighlights(data.highlights || {}))
|
||||
.catch(err => console.error('Error loading highlights:', err))
|
||||
}
|
||||
} else {
|
||||
setHighlights({})
|
||||
}
|
||||
}, [verses, user, locale])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -1068,25 +1119,124 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
})
|
||||
}
|
||||
|
||||
const handleVerseMenuAction = (action: 'bookmark' | 'copy' | 'chat') => {
|
||||
const handleVerseMenuAction = (action: 'bookmark' | 'copy' | 'chat' | 'highlight') => {
|
||||
if (!verseMenuAnchor.verse) return
|
||||
|
||||
const verse = verseMenuAnchor.verse
|
||||
handleVerseMenuClose()
|
||||
|
||||
switch (action) {
|
||||
case 'bookmark':
|
||||
handleVerseBookmark(verse)
|
||||
handleVerseMenuClose()
|
||||
break
|
||||
case 'copy':
|
||||
handleCopyVerse(verse)
|
||||
handleVerseMenuClose()
|
||||
break
|
||||
case 'chat':
|
||||
handleVerseChat(verse)
|
||||
handleVerseMenuClose()
|
||||
break
|
||||
case 'highlight':
|
||||
// Keep menu open, show color picker instead
|
||||
setHighlightColorPickerAnchor({
|
||||
element: verseMenuAnchor.element,
|
||||
verse: verse
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleHighlightVerse = async (verse: BibleVerse, color: TextHighlight['color']) => {
|
||||
// If user is not authenticated, redirect to login
|
||||
if (!user) {
|
||||
router.push(`/${locale}/login?redirect=${encodeURIComponent(`/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}&verse=${verse.verseNum}`)}`)
|
||||
return
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
// Check if verse already has a highlight
|
||||
const existingHighlight = highlights[verse.id]
|
||||
|
||||
if (existingHighlight) {
|
||||
// Update highlight color
|
||||
const response = await fetch(`/api/highlights/${existingHighlight.id}?locale=${locale}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ color })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setHighlights(prev => ({
|
||||
...prev,
|
||||
[verse.id]: data.highlight
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
// Create new highlight
|
||||
const response = await fetch(`/api/highlights?locale=${locale}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ verseId: verse.id, color })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setHighlights(prev => ({
|
||||
...prev,
|
||||
[verse.id]: data.highlight
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error highlighting verse:', error)
|
||||
}
|
||||
|
||||
// Close color picker and menu
|
||||
setHighlightColorPickerAnchor({ element: null, verse: null })
|
||||
handleVerseMenuClose()
|
||||
}
|
||||
|
||||
const handleRemoveHighlight = async (verse: BibleVerse) => {
|
||||
if (!user) return
|
||||
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const highlight = highlights[verse.id]
|
||||
if (!highlight) return
|
||||
|
||||
const response = await fetch(`/api/highlights/${highlight.id}?locale=${locale}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setHighlights(prev => {
|
||||
const newHighlights = { ...prev }
|
||||
delete newHighlights[verse.id]
|
||||
return newHighlights
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing highlight:', error)
|
||||
}
|
||||
|
||||
setHighlightColorPickerAnchor({ element: null, verse: null })
|
||||
handleVerseMenuClose()
|
||||
}
|
||||
|
||||
const handleSetFavoriteVersion = async () => {
|
||||
if (!user) {
|
||||
router.push(`/${locale}/login?redirect=${encodeURIComponent(`/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}`)}`)
|
||||
@@ -1222,9 +1372,23 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
}
|
||||
}
|
||||
|
||||
const getHighlightColor = (color: TextHighlight['color'], theme: 'light' | 'dark' | 'sepia') => {
|
||||
const colors = {
|
||||
yellow: { light: '#fff9c4', dark: '#7f6000', sepia: '#f5e6b3' },
|
||||
green: { light: '#c8e6c9', dark: '#2e7d32', sepia: '#d4e8d4' },
|
||||
blue: { light: '#bbdefb', dark: '#1565c0', sepia: '#c8dce8' },
|
||||
purple: { light: '#e1bee7', dark: '#6a1b9a', sepia: '#e3d4e8' },
|
||||
orange: { light: '#ffe0b2', dark: '#e65100', sepia: '#f5ddc8' },
|
||||
pink: { light: '#f8bbd0', dark: '#c2185b', sepia: '#f5d8e3' },
|
||||
red: { light: '#ffcdd2', dark: '#c62828', sepia: '#f5d0d4' }
|
||||
}
|
||||
return colors[color][theme]
|
||||
}
|
||||
|
||||
const renderVerse = (verse: BibleVerse) => {
|
||||
const isBookmarked = !!verseBookmarks[verse.id]
|
||||
const isHighlighted = highlightedVerse === verse.verseNum
|
||||
const highlight = highlights[verse.id]
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -1232,7 +1396,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
ref={(el: HTMLDivElement | null) => { if (el) verseRefs.current[verse.verseNum] = el }}
|
||||
data-verse-container
|
||||
sx={{
|
||||
mb: 1,
|
||||
mb: `${preferences.fontSize * preferences.lineHeight * (preferences.paragraphSpacing - 1)}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
@@ -1241,21 +1405,25 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ flex: 1, maxWidth: `${preferences.maxLineLength}ch` }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: `${preferences.fontSize}px`,
|
||||
lineHeight: preferences.lineHeight,
|
||||
fontFamily: preferences.fontFamily === 'serif' ? 'Georgia, serif' : 'Arial, sans-serif',
|
||||
letterSpacing: `${preferences.letterSpacing}px`,
|
||||
wordSpacing: `${preferences.wordSpacing}px`,
|
||||
display: 'inline',
|
||||
backgroundColor: isHighlighted
|
||||
? 'primary.light'
|
||||
: isBookmarked
|
||||
? 'warning.light'
|
||||
: 'transparent',
|
||||
borderRadius: (isBookmarked || isHighlighted) ? 1 : 0,
|
||||
padding: (isBookmarked || isHighlighted) ? '4px 8px' : 0,
|
||||
: highlight
|
||||
? getHighlightColor(highlight.color, preferences.theme)
|
||||
: isBookmarked
|
||||
? 'warning.light'
|
||||
: 'transparent',
|
||||
borderRadius: (isBookmarked || isHighlighted || highlight) ? 1 : 0,
|
||||
padding: (isBookmarked || isHighlighted || highlight) ? '4px 8px' : 0,
|
||||
transition: 'all 0.3s ease',
|
||||
border: isHighlighted ? '2px solid' : 'none',
|
||||
borderColor: 'primary.main',
|
||||
@@ -1629,6 +1797,58 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>Letter Spacing</Typography>
|
||||
<Slider
|
||||
value={preferences.letterSpacing}
|
||||
onChange={(_, value) => setPreferences(prev => ({ ...prev, letterSpacing: value as number }))}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
marks
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>Word Spacing</Typography>
|
||||
<Slider
|
||||
value={preferences.wordSpacing}
|
||||
onChange={(_, value) => setPreferences(prev => ({ ...prev, wordSpacing: value as number }))}
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.5}
|
||||
marks
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>Paragraph Spacing</Typography>
|
||||
<Slider
|
||||
value={preferences.paragraphSpacing}
|
||||
onChange={(_, value) => setPreferences(prev => ({ ...prev, paragraphSpacing: value as number }))}
|
||||
min={1.0}
|
||||
max={2.5}
|
||||
step={0.1}
|
||||
marks
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>Max Line Length</Typography>
|
||||
<Slider
|
||||
value={preferences.maxLineLength}
|
||||
onChange={(_, value) => setPreferences(prev => ({ ...prev, maxLineLength: value as number }))}
|
||||
min={50}
|
||||
max={100}
|
||||
step={5}
|
||||
marks
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>{t('fontFamily')}</Typography>
|
||||
<ButtonGroup fullWidth>
|
||||
@@ -2004,6 +2224,14 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
{verseMenuAnchor.verse && verseBookmarks[verseMenuAnchor.verse.id] ? 'Remove Bookmark' : 'Bookmark'}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleVerseMenuAction('highlight')}>
|
||||
<ListItemIcon>
|
||||
<Palette fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{verseMenuAnchor.verse && highlights[verseMenuAnchor.verse.id] ? 'Change Highlight' : 'Highlight'}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleVerseMenuAction('copy')}>
|
||||
<ListItemIcon>
|
||||
<ContentCopy fontSize="small" />
|
||||
@@ -2018,6 +2246,73 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Highlight Color Picker */}
|
||||
<Menu
|
||||
anchorEl={highlightColorPickerAnchor.element}
|
||||
open={Boolean(highlightColorPickerAnchor.element)}
|
||||
onClose={() => {
|
||||
setHighlightColorPickerAnchor({ element: null, verse: null })
|
||||
handleVerseMenuClose()
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Select Highlight Color
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', maxWidth: 200 }}>
|
||||
{(['yellow', 'green', 'blue', 'purple', 'orange', 'pink', 'red'] as const).map(color => (
|
||||
<IconButton
|
||||
key={color}
|
||||
onClick={() => {
|
||||
if (highlightColorPickerAnchor.verse) {
|
||||
handleHighlightVerse(highlightColorPickerAnchor.verse, color)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: getHighlightColor(color, preferences.theme),
|
||||
border: highlightColorPickerAnchor.verse && highlights[highlightColorPickerAnchor.verse.id]?.color === color
|
||||
? '3px solid'
|
||||
: '1px solid',
|
||||
borderColor: highlightColorPickerAnchor.verse && highlights[highlightColorPickerAnchor.verse.id]?.color === color
|
||||
? 'primary.main'
|
||||
: 'divider',
|
||||
'&:hover': {
|
||||
backgroundColor: getHighlightColor(color, preferences.theme),
|
||||
opacity: 0.8
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{highlightColorPickerAnchor.verse && highlights[highlightColorPickerAnchor.verse.id] && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={() => {
|
||||
if (highlightColorPickerAnchor.verse) {
|
||||
handleRemoveHighlight(highlightColorPickerAnchor.verse)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove Highlight
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Menu>
|
||||
|
||||
{/* Copy Feedback */}
|
||||
<Snackbar
|
||||
open={copyFeedback.open}
|
||||
|
||||
Reference in New Issue
Block a user