Implement complete bookmark functionality for Bible reading

Add both chapter-level and verse-level bookmarking capabilities:

Database Changes:
- Add ChapterBookmark table for chapter-level bookmarks
- Update schema with proper relationships to User and BibleBook models
- Maintain existing Bookmark table for verse-level bookmarks

API Endpoints:
- /api/bookmarks/chapter (GET, POST, DELETE) with check endpoint
- /api/bookmarks/verse (GET, POST, DELETE) with check and bulk-check endpoints
- JWT authentication required for all bookmark operations
- Multilingual error messages (Romanian/English)

Frontend Implementation:
- Chapter bookmark button in Bible page header with visual state feedback
- Individual verse bookmark icons with hover-to-reveal UI
- Highlighted background for bookmarked verses
- Efficient bulk checking for verse bookmarks per chapter
- Real-time UI updates without page refresh

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
andupetcu
2025-09-21 01:25:50 +03:00
parent e4acac270e
commit 1b43b4e1e3
9 changed files with 890 additions and 31 deletions

View File

@@ -21,16 +21,19 @@ import {
useTheme,
CircularProgress,
Skeleton,
IconButton,
} from '@mui/material'
import {
MenuBook,
NavigateBefore,
NavigateNext,
Bookmark,
BookmarkBorder,
Share,
} from '@mui/icons-material'
import { useState, useEffect } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import { useAuth } from '@/hooks/use-auth'
interface BibleVerse {
id: string
@@ -62,6 +65,11 @@ export default function BiblePage() {
const [selectedChapter, setSelectedChapter] = useState<number>(1)
const [verses, setVerses] = useState<BibleVerse[]>([])
const [loading, setLoading] = useState(true)
const [isBookmarked, setIsBookmarked] = useState(false)
const [bookmarkLoading, setBookmarkLoading] = useState(false)
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
const [verseBookmarkLoading, setVerseBookmarkLoading] = useState<{[key: string]: boolean}>({})
const { user } = useAuth()
// Fetch available books
useEffect(() => {
@@ -97,6 +105,56 @@ export default function BiblePage() {
}
}, [selectedBook, selectedChapter])
// Check if chapter is bookmarked
useEffect(() => {
if (selectedBook && selectedChapter && user) {
const token = localStorage.getItem('authToken')
if (token) {
fetch(`/api/bookmarks/chapter/check?bookId=${selectedBook}&chapterNum=${selectedChapter}&locale=${locale}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(data => {
setIsBookmarked(data.isBookmarked || false)
})
.catch(err => {
console.error('Error checking bookmark:', err)
})
}
} else {
setIsBookmarked(false)
}
}, [selectedBook, selectedChapter, user, locale])
// Check verse bookmarks when verses change
useEffect(() => {
if (verses.length > 0 && user) {
const token = localStorage.getItem('authToken')
if (token) {
const verseIds = verses.map(verse => verse.id)
fetch(`/api/bookmarks/verse/bulk-check?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ verseIds })
})
.then(res => res.json())
.then(data => {
setVerseBookmarks(data.bookmarks || {})
})
.catch(err => {
console.error('Error checking verse bookmarks:', err)
})
}
} else {
setVerseBookmarks({})
}
}, [verses, user, locale])
const currentBook = books.find(book => book.id === selectedBook)
const maxChapters = currentBook?.chapters?.length || 50 // Default fallback
@@ -121,6 +179,113 @@ export default function BiblePage() {
}
}
const handleBookmarkToggle = async () => {
if (!user || !selectedBook || !selectedChapter) return
setBookmarkLoading(true)
const token = localStorage.getItem('authToken')
if (!token) {
setBookmarkLoading(false)
return
}
try {
if (isBookmarked) {
// Remove bookmark
const response = await fetch(`/api/bookmarks/chapter?bookId=${selectedBook}&chapterNum=${selectedChapter}&locale=${locale}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setIsBookmarked(false)
}
} else {
// Add bookmark
const response = await fetch(`/api/bookmarks/chapter?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
bookId: selectedBook,
chapterNum: selectedChapter
})
})
if (response.ok) {
setIsBookmarked(true)
}
}
} catch (error) {
console.error('Error toggling bookmark:', error)
} finally {
setBookmarkLoading(false)
}
}
const handleVerseBookmarkToggle = async (verse: BibleVerse) => {
if (!user) return
setVerseBookmarkLoading(prev => ({ ...prev, [verse.id]: true }))
const token = localStorage.getItem('authToken')
if (!token) {
setVerseBookmarkLoading(prev => ({ ...prev, [verse.id]: false }))
return
}
try {
const isCurrentlyBookmarked = !!verseBookmarks[verse.id]
if (isCurrentlyBookmarked) {
// Remove verse bookmark
const response = await fetch(`/api/bookmarks/verse?verseId=${verse.id}&locale=${locale}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setVerseBookmarks(prev => {
const newBookmarks = { ...prev }
delete newBookmarks[verse.id]
return newBookmarks
})
}
} else {
// Add verse bookmark
const response = await fetch(`/api/bookmarks/verse?locale=${locale}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
verseId: verse.id
})
})
if (response.ok) {
const data = await response.json()
setVerseBookmarks(prev => ({
...prev,
[verse.id]: data.bookmark
}))
}
}
} catch (error) {
console.error('Error toggling verse bookmark:', error)
} finally {
setVerseBookmarkLoading(prev => ({ ...prev, [verse.id]: false }))
}
}
if (loading && books.length === 0) {
return (
<Box>
@@ -223,10 +388,13 @@ export default function BiblePage() {
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
startIcon={<Bookmark />}
variant="outlined"
variant={isBookmarked ? "contained" : "outlined"}
size="small"
onClick={handleBookmarkToggle}
disabled={!user || bookmarkLoading}
color={isBookmarked ? "primary" : "inherit"}
>
{t('save')}
{bookmarkLoading ? t('saving') : (isBookmarked ? t('saved') : t('save'))}
</Button>
<Button
startIcon={<Share />}
@@ -253,38 +421,68 @@ export default function BiblePage() {
</Box>
) : verses.length > 0 ? (
<Box>
{verses.map((verse) => (
<Box key={verse.id} sx={{ mb: 2 }}>
<Typography
variant="body1"
component="p"
{verses.map((verse) => {
const isVerseBookmarked = !!verseBookmarks[verse.id]
const isVerseLoading = !!verseBookmarkLoading[verse.id]
return (
<Box
key={verse.id}
sx={{
lineHeight: 1.8,
fontSize: '1.1rem',
'&:hover': {
bgcolor: 'action.hover',
cursor: 'pointer',
borderRadius: 1,
p: 1,
m: -1,
},
mb: 2,
display: 'flex',
alignItems: 'flex-start',
gap: 1,
'&:hover .verse-bookmark': {
opacity: 1
}
}}
>
<Typography
component="span"
sx={{
fontWeight: 'bold',
color: 'primary.main',
mr: 1,
fontSize: '0.9rem',
}}
>
{verse.verseNum}
</Typography>
{verse.text}
</Typography>
</Box>
))}
<Box sx={{ flex: 1 }}>
<Typography
variant="body1"
component="p"
sx={{
lineHeight: 1.8,
fontSize: '1.1rem',
bgcolor: isVerseBookmarked ? 'warning.light' : 'transparent',
borderRadius: isVerseBookmarked ? 1 : 0,
p: isVerseBookmarked ? 1 : 0,
}}
>
<Typography
component="span"
sx={{
fontWeight: 'bold',
color: 'primary.main',
mr: 1,
fontSize: '0.9rem',
}}
>
{verse.verseNum}
</Typography>
{verse.text}
</Typography>
</Box>
{user && (
<IconButton
className="verse-bookmark"
size="small"
onClick={() => handleVerseBookmarkToggle(verse)}
disabled={isVerseLoading}
sx={{
opacity: isVerseBookmarked ? 1 : 0.3,
transition: 'opacity 0.2s',
color: isVerseBookmarked ? 'warning.main' : 'action.active'
}}
>
{isVerseBookmarked ? <Bookmark fontSize="small" /> : <BookmarkBorder fontSize="small" />}
</IconButton>
)}
</Box>
)
})}
</Box>
) : (
<Typography textAlign="center" color="text.secondary">