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[]
|
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 {
|
interface BibleVersion {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -122,6 +132,10 @@ interface ReadingPreferences {
|
|||||||
showVerseNumbers: boolean
|
showVerseNumbers: boolean
|
||||||
columnLayout: boolean
|
columnLayout: boolean
|
||||||
readingMode: 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 = {
|
const defaultPreferences: ReadingPreferences = {
|
||||||
@@ -131,7 +145,11 @@ const defaultPreferences: ReadingPreferences = {
|
|||||||
theme: 'light',
|
theme: 'light',
|
||||||
showVerseNumbers: true,
|
showVerseNumbers: true,
|
||||||
columnLayout: false,
|
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 {
|
interface BibleReaderProps {
|
||||||
@@ -205,6 +223,16 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
|
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
|
||||||
const [bookmarkLoading, setBookmarkLoading] = useState(false)
|
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
|
// Reading progress state
|
||||||
const [readingProgress, setReadingProgress] = useState<any>(null)
|
const [readingProgress, setReadingProgress] = useState<any>(null)
|
||||||
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
|
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
|
||||||
@@ -795,6 +823,29 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}
|
}
|
||||||
}, [verses, user, locale])
|
}, [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
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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
|
if (!verseMenuAnchor.verse) return
|
||||||
|
|
||||||
const verse = verseMenuAnchor.verse
|
const verse = verseMenuAnchor.verse
|
||||||
handleVerseMenuClose()
|
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'bookmark':
|
case 'bookmark':
|
||||||
handleVerseBookmark(verse)
|
handleVerseBookmark(verse)
|
||||||
|
handleVerseMenuClose()
|
||||||
break
|
break
|
||||||
case 'copy':
|
case 'copy':
|
||||||
handleCopyVerse(verse)
|
handleCopyVerse(verse)
|
||||||
|
handleVerseMenuClose()
|
||||||
break
|
break
|
||||||
case 'chat':
|
case 'chat':
|
||||||
handleVerseChat(verse)
|
handleVerseChat(verse)
|
||||||
|
handleVerseMenuClose()
|
||||||
|
break
|
||||||
|
case 'highlight':
|
||||||
|
// Keep menu open, show color picker instead
|
||||||
|
setHighlightColorPickerAnchor({
|
||||||
|
element: verseMenuAnchor.element,
|
||||||
|
verse: verse
|
||||||
|
})
|
||||||
break
|
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 () => {
|
const handleSetFavoriteVersion = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.push(`/${locale}/login?redirect=${encodeURIComponent(`/${locale}/bible?version=${selectedVersion}&book=${selectedBook}&chapter=${selectedChapter}`)}`)
|
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 renderVerse = (verse: BibleVerse) => {
|
||||||
const isBookmarked = !!verseBookmarks[verse.id]
|
const isBookmarked = !!verseBookmarks[verse.id]
|
||||||
const isHighlighted = highlightedVerse === verse.verseNum
|
const isHighlighted = highlightedVerse === verse.verseNum
|
||||||
|
const highlight = highlights[verse.id]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -1232,7 +1396,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
ref={(el: HTMLDivElement | null) => { if (el) verseRefs.current[verse.verseNum] = el }}
|
ref={(el: HTMLDivElement | null) => { if (el) verseRefs.current[verse.verseNum] = el }}
|
||||||
data-verse-container
|
data-verse-container
|
||||||
sx={{
|
sx={{
|
||||||
mb: 1,
|
mb: `${preferences.fontSize * preferences.lineHeight * (preferences.paragraphSpacing - 1)}px`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: 1,
|
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
|
<Typography
|
||||||
component="span"
|
component="span"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: `${preferences.fontSize}px`,
|
fontSize: `${preferences.fontSize}px`,
|
||||||
lineHeight: preferences.lineHeight,
|
lineHeight: preferences.lineHeight,
|
||||||
fontFamily: preferences.fontFamily === 'serif' ? 'Georgia, serif' : 'Arial, sans-serif',
|
fontFamily: preferences.fontFamily === 'serif' ? 'Georgia, serif' : 'Arial, sans-serif',
|
||||||
|
letterSpacing: `${preferences.letterSpacing}px`,
|
||||||
|
wordSpacing: `${preferences.wordSpacing}px`,
|
||||||
display: 'inline',
|
display: 'inline',
|
||||||
backgroundColor: isHighlighted
|
backgroundColor: isHighlighted
|
||||||
? 'primary.light'
|
? 'primary.light'
|
||||||
: isBookmarked
|
: highlight
|
||||||
? 'warning.light'
|
? getHighlightColor(highlight.color, preferences.theme)
|
||||||
: 'transparent',
|
: isBookmarked
|
||||||
borderRadius: (isBookmarked || isHighlighted) ? 1 : 0,
|
? 'warning.light'
|
||||||
padding: (isBookmarked || isHighlighted) ? '4px 8px' : 0,
|
: 'transparent',
|
||||||
|
borderRadius: (isBookmarked || isHighlighted || highlight) ? 1 : 0,
|
||||||
|
padding: (isBookmarked || isHighlighted || highlight) ? '4px 8px' : 0,
|
||||||
transition: 'all 0.3s ease',
|
transition: 'all 0.3s ease',
|
||||||
border: isHighlighted ? '2px solid' : 'none',
|
border: isHighlighted ? '2px solid' : 'none',
|
||||||
borderColor: 'primary.main',
|
borderColor: 'primary.main',
|
||||||
@@ -1629,6 +1797,58 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
<Box>
|
||||||
<Typography gutterBottom>{t('fontFamily')}</Typography>
|
<Typography gutterBottom>{t('fontFamily')}</Typography>
|
||||||
<ButtonGroup fullWidth>
|
<ButtonGroup fullWidth>
|
||||||
@@ -2004,6 +2224,14 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
{verseMenuAnchor.verse && verseBookmarks[verseMenuAnchor.verse.id] ? 'Remove Bookmark' : 'Bookmark'}
|
{verseMenuAnchor.verse && verseBookmarks[verseMenuAnchor.verse.id] ? 'Remove Bookmark' : 'Bookmark'}
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
</MenuItem>
|
</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')}>
|
<MenuItem onClick={() => handleVerseMenuAction('copy')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<ContentCopy fontSize="small" />
|
<ContentCopy fontSize="small" />
|
||||||
@@ -2018,6 +2246,73 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</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 */}
|
{/* Copy Feedback */}
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={copyFeedback.open}
|
open={copyFeedback.open}
|
||||||
|
|||||||
85
app/api/highlights/[id]/route.ts
Normal file
85
app/api/highlights/[id]/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyToken } from '@/lib/auth'
|
||||||
|
|
||||||
|
// PUT /api/highlights/[id]?locale=en - Update highlight
|
||||||
|
// DELETE /api/highlights/[id]?locale=en - Delete highlight
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('authorization')
|
||||||
|
if (!authHeader) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
const decoded = await verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid token' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { color, note, tags } = body
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const existingHighlight = await prisma.highlight.findUnique({
|
||||||
|
where: { id: params.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingHighlight || existingHighlight.userId !== decoded.userId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Highlight not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight = await prisma.highlight.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: {
|
||||||
|
...(color && { color }),
|
||||||
|
...(note !== undefined && { note }),
|
||||||
|
...(tags && { tags })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, highlight })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating highlight:', error)
|
||||||
|
return NextResponse.json({ success: false, error: 'Failed to update highlight' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('authorization')
|
||||||
|
if (!authHeader) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
const decoded = await verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid token' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const existingHighlight = await prisma.highlight.findUnique({
|
||||||
|
where: { id: params.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingHighlight || existingHighlight.userId !== decoded.userId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Highlight not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.highlight.delete({
|
||||||
|
where: { id: params.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting highlight:', error)
|
||||||
|
return NextResponse.json({ success: false, error: 'Failed to delete highlight' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/api/highlights/bulk/route.ts
Normal file
44
app/api/highlights/bulk/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyToken } from '@/lib/auth'
|
||||||
|
|
||||||
|
// POST /api/highlights/bulk?locale=en - Get highlights for multiple verses
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('authorization')
|
||||||
|
if (!authHeader) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
const decoded = await verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid token' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { verseIds } = body
|
||||||
|
|
||||||
|
if (!Array.isArray(verseIds)) {
|
||||||
|
return NextResponse.json({ success: false, error: 'verseIds must be an array' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlights = await prisma.highlight.findMany({
|
||||||
|
where: {
|
||||||
|
userId: decoded.userId,
|
||||||
|
verseId: { in: verseIds }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert array to object keyed by verseId for easier lookup
|
||||||
|
const highlightsMap: { [key: string]: any } = {}
|
||||||
|
highlights.forEach(highlight => {
|
||||||
|
highlightsMap[highlight.verseId] = highlight
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, highlights: highlightsMap })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching highlights:', error)
|
||||||
|
return NextResponse.json({ success: false, error: 'Failed to fetch highlights' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/api/highlights/route.ts
Normal file
81
app/api/highlights/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { verifyToken } from '@/lib/auth'
|
||||||
|
|
||||||
|
// GET /api/highlights?locale=en - Get all highlights for user
|
||||||
|
// POST /api/highlights?locale=en - Create new highlight
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('authorization')
|
||||||
|
if (!authHeader) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
const decoded = await verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid token' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlights = await prisma.highlight.findMany({
|
||||||
|
where: { userId: decoded.userId },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, highlights })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching highlights:', error)
|
||||||
|
return NextResponse.json({ success: false, error: 'Failed to fetch highlights' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('authorization')
|
||||||
|
if (!authHeader) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
const decoded = await verifyToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid token' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { verseId, color, note, tags } = body
|
||||||
|
|
||||||
|
if (!verseId || !color) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Missing required fields' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if highlight already exists
|
||||||
|
const existingHighlight = await prisma.highlight.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_verseId: {
|
||||||
|
userId: decoded.userId,
|
||||||
|
verseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingHighlight) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Highlight already exists' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight = await prisma.highlight.create({
|
||||||
|
data: {
|
||||||
|
userId: decoded.userId,
|
||||||
|
verseId,
|
||||||
|
color,
|
||||||
|
note,
|
||||||
|
tags: tags || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, highlight })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating highlight:', error)
|
||||||
|
return NextResponse.json({ success: false, error: 'Failed to create highlight' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
bookmarks Bookmark[]
|
bookmarks Bookmark[]
|
||||||
chapterBookmarks ChapterBookmark[]
|
chapterBookmarks ChapterBookmark[]
|
||||||
|
highlights Highlight[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
chatMessages ChatMessage[]
|
chatMessages ChatMessage[]
|
||||||
chatConversations ChatConversation[]
|
chatConversations ChatConversation[]
|
||||||
@@ -116,6 +117,7 @@ model BibleVerse {
|
|||||||
chapter BibleChapter @relation(fields: [chapterId], references: [id])
|
chapter BibleChapter @relation(fields: [chapterId], references: [id])
|
||||||
bookmarks Bookmark[]
|
bookmarks Bookmark[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
|
highlights Highlight[]
|
||||||
|
|
||||||
@@unique([chapterId, verseNum])
|
@@unique([chapterId, verseNum])
|
||||||
@@index([chapterId])
|
@@index([chapterId])
|
||||||
@@ -210,6 +212,24 @@ model ChapterBookmark {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Highlight {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
verseId String
|
||||||
|
color String // yellow, green, blue, purple, orange, pink, red
|
||||||
|
note String? @db.Text
|
||||||
|
tags String[] @default([])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
verse BibleVerse @relation(fields: [verseId], references: [id])
|
||||||
|
|
||||||
|
@@unique([userId, verseId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([verseId])
|
||||||
|
}
|
||||||
|
|
||||||
model Note {
|
model Note {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
Reference in New Issue
Block a user