Create comprehensive bookmarks management page
Add complete bookmarks page with navigation functionality: Features: - Dedicated /bookmarks page for viewing all saved bookmarks - Support for both chapter and verse bookmarks in unified view - Statistics dashboard showing total, chapter, and verse bookmark counts - Tabbed filtering (All, Chapters, Verses) for easy organization - Direct navigation to Bible reading page with URL parameters - Delete functionality for individual bookmarks - Empty state with call-to-action to start reading Navigation Integration: - Add Bookmarks to main navigation menu (authenticated users only) - Add Bookmarks to user profile dropdown menu - Dynamic navigation based on authentication state Bible Page Enhancements: - URL parameter support for bookmark navigation (book, chapter, verse) - Verse highlighting when navigating from bookmarks - Auto-clear highlight after 3 seconds for better UX API Endpoints: - /api/bookmarks/all - Unified endpoint for all user bookmarks - Returns transformed data optimized for frontend consumption Multilingual Support: - Full Romanian and English translations - Consistent messaging across all bookmark interfaces 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/use-auth'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
interface BibleVerse {
|
||||
id: string
|
||||
@@ -60,6 +61,7 @@ export default function BiblePage() {
|
||||
const theme = useTheme()
|
||||
const t = useTranslations('pages.bible')
|
||||
const locale = useLocale()
|
||||
const searchParams = useSearchParams()
|
||||
const [books, setBooks] = useState<BibleBook[]>([])
|
||||
const [selectedBook, setSelectedBook] = useState<string>('')
|
||||
const [selectedChapter, setSelectedChapter] = useState<number>(1)
|
||||
@@ -69,6 +71,7 @@ export default function BiblePage() {
|
||||
const [bookmarkLoading, setBookmarkLoading] = useState(false)
|
||||
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
|
||||
const [verseBookmarkLoading, setVerseBookmarkLoading] = useState<{[key: string]: boolean}>({})
|
||||
const [highlightedVerse, setHighlightedVerse] = useState<number | null>(null)
|
||||
const { user } = useAuth()
|
||||
|
||||
// Fetch available books
|
||||
@@ -88,6 +91,38 @@ export default function BiblePage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle URL parameters for navigation from bookmarks
|
||||
useEffect(() => {
|
||||
if (books.length > 0) {
|
||||
const bookParam = searchParams.get('book')
|
||||
const chapterParam = searchParams.get('chapter')
|
||||
const verseParam = searchParams.get('verse')
|
||||
|
||||
if (bookParam) {
|
||||
const book = books.find(b => b.id === bookParam)
|
||||
if (book) {
|
||||
setSelectedBook(bookParam)
|
||||
|
||||
if (chapterParam) {
|
||||
const chapter = parseInt(chapterParam)
|
||||
if (chapter > 0) {
|
||||
setSelectedChapter(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
if (verseParam) {
|
||||
const verse = parseInt(verseParam)
|
||||
if (verse > 0) {
|
||||
setHighlightedVerse(verse)
|
||||
// Clear highlight after 3 seconds
|
||||
setTimeout(() => setHighlightedVerse(null), 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [books, searchParams])
|
||||
|
||||
// Fetch verses when book/chapter changes
|
||||
useEffect(() => {
|
||||
if (selectedBook && selectedChapter) {
|
||||
@@ -445,9 +480,16 @@ export default function BiblePage() {
|
||||
sx={{
|
||||
lineHeight: 1.8,
|
||||
fontSize: '1.1rem',
|
||||
bgcolor: isVerseBookmarked ? 'warning.light' : 'transparent',
|
||||
borderRadius: isVerseBookmarked ? 1 : 0,
|
||||
p: isVerseBookmarked ? 1 : 0,
|
||||
bgcolor: highlightedVerse === verse.verseNum
|
||||
? 'primary.light'
|
||||
: isVerseBookmarked
|
||||
? 'warning.light'
|
||||
: 'transparent',
|
||||
borderRadius: (isVerseBookmarked || highlightedVerse === verse.verseNum) ? 1 : 0,
|
||||
p: (isVerseBookmarked || highlightedVerse === verse.verseNum) ? 1 : 0,
|
||||
transition: 'all 0.3s ease',
|
||||
border: highlightedVerse === verse.verseNum ? '2px solid' : 'none',
|
||||
borderColor: 'primary.main',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
|
||||
402
app/[locale]/bookmarks/page.tsx
Normal file
402
app/[locale]/bookmarks/page.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/use-auth'
|
||||
import { ProtectedRoute } from '@/components/auth/protected-route'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tab,
|
||||
Tabs,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemSecondary
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Bookmark,
|
||||
BookmarkBorder,
|
||||
MenuBook,
|
||||
Article,
|
||||
Delete,
|
||||
Launch,
|
||||
AccessTime,
|
||||
Note
|
||||
} from '@mui/icons-material'
|
||||
|
||||
interface BookmarkItem {
|
||||
id: string
|
||||
type: 'chapter' | 'verse'
|
||||
title: string
|
||||
subtitle: string
|
||||
note?: string
|
||||
createdAt: string
|
||||
color?: string
|
||||
text?: string
|
||||
navigation: {
|
||||
bookId: string
|
||||
chapterNum: number
|
||||
verseNum?: number
|
||||
}
|
||||
verse?: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BookmarkStats {
|
||||
total: number
|
||||
chapters: number
|
||||
verses: number
|
||||
}
|
||||
|
||||
export default function BookmarksPage() {
|
||||
const { user } = useAuth()
|
||||
const t = useTranslations('bookmarks')
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
|
||||
const [bookmarks, setBookmarks] = useState<BookmarkItem[]>([])
|
||||
const [stats, setStats] = useState<BookmarkStats>({ total: 0, chapters: 0, verses: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch bookmarks
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchBookmarks()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const fetchBookmarks = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (!token) {
|
||||
setError(t('authRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/bookmarks/all?locale=${locale}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setBookmarks(data.bookmarks || [])
|
||||
setStats(data.stats || { total: 0, chapters: 0, verses: 0 })
|
||||
} else {
|
||||
const data = await response.json()
|
||||
setError(data.error || t('loadError'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookmarks:', error)
|
||||
setError(t('loadError'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigateToBookmark = (bookmark: BookmarkItem) => {
|
||||
const params = new URLSearchParams({
|
||||
book: bookmark.navigation.bookId,
|
||||
chapter: bookmark.navigation.chapterNum.toString()
|
||||
})
|
||||
|
||||
if (bookmark.navigation.verseNum) {
|
||||
params.set('verse', bookmark.navigation.verseNum.toString())
|
||||
}
|
||||
|
||||
router.push(`/${locale}/bible?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleDeleteBookmark = async (bookmark: BookmarkItem) => {
|
||||
if (!user) return
|
||||
|
||||
setDeletingIds(prev => new Set(prev).add(bookmark.id))
|
||||
const token = localStorage.getItem('authToken')
|
||||
|
||||
if (!token) {
|
||||
setDeletingIds(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(bookmark.id)
|
||||
return newSet
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = bookmark.type === 'chapter'
|
||||
? `/api/bookmarks/chapter?bookId=${bookmark.navigation.bookId}&chapterNum=${bookmark.navigation.chapterNum}&locale=${locale}`
|
||||
: `/api/bookmarks/verse?verseId=${bookmark.verse.id}&locale=${locale}`
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Remove from local state
|
||||
setBookmarks(prev => prev.filter(b => b.id !== bookmark.id))
|
||||
setStats(prev => ({
|
||||
total: prev.total - 1,
|
||||
chapters: bookmark.type === 'chapter' ? prev.chapters - 1 : prev.chapters,
|
||||
verses: bookmark.type === 'verse' ? prev.verses - 1 : prev.verses
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting bookmark:', error)
|
||||
} finally {
|
||||
setDeletingIds(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(bookmark.id)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const filteredBookmarks = () => {
|
||||
switch (activeTab) {
|
||||
case 1: return bookmarks.filter(b => b.type === 'chapter')
|
||||
case 2: return bookmarks.filter(b => b.type === 'verse')
|
||||
default: return bookmarks
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<CircularProgress size={48} />
|
||||
</Box>
|
||||
</Container>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box textAlign="center" mb={4}>
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
<Bookmark sx={{ fontSize: 40, mr: 2, verticalAlign: 'middle' }} />
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary">
|
||||
{stats.total}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('totalBookmarks')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success.main">
|
||||
{stats.chapters}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('chapterBookmarks')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="warning.main">
|
||||
{stats.verses}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('verseBookmarks')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={activeTab} onChange={(_, newValue) => setActiveTab(newValue)}>
|
||||
<Tab
|
||||
label={`${t('allBookmarks')} (${stats.total})`}
|
||||
icon={<Bookmark />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label={`${t('chapters')} (${stats.chapters})`}
|
||||
icon={<MenuBook />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label={`${t('verses')} (${stats.verses})`}
|
||||
icon={<Article />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Bookmarks List */}
|
||||
{filteredBookmarks().length === 0 ? (
|
||||
<Box textAlign="center" py={6}>
|
||||
<BookmarkBorder sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('noBookmarks')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('noBookmarksDescription')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => router.push(`/${locale}/bible`)}
|
||||
startIcon={<MenuBook />}
|
||||
>
|
||||
{t('startReading')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{filteredBookmarks().map((bookmark) => (
|
||||
<Grid item xs={12} key={bookmark.id}>
|
||||
<Card variant="outlined" sx={{ transition: 'all 0.2s', '&:hover': { elevation: 2 } }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box flex={1}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={1}>
|
||||
{bookmark.type === 'chapter' ? (
|
||||
<MenuBook color="primary" fontSize="small" />
|
||||
) : (
|
||||
<Article color="warning" fontSize="small" />
|
||||
)}
|
||||
<Typography variant="h6" component="h3">
|
||||
{bookmark.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={bookmark.type === 'chapter' ? t('chapter') : t('verse')}
|
||||
size="small"
|
||||
color={bookmark.type === 'chapter' ? 'primary' : 'warning'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{bookmark.subtitle}
|
||||
</Typography>
|
||||
|
||||
{bookmark.text && (
|
||||
<Typography variant="body1" sx={{
|
||||
my: 1,
|
||||
p: 1,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 1,
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
"{bookmark.text}"
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{bookmark.note && (
|
||||
<Box display="flex" alignItems="center" gap={1} mt={1}>
|
||||
<Note fontSize="small" color="action" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{bookmark.note}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box display="flex" alignItems="center" gap={1} mt={1}>
|
||||
<AccessTime fontSize="small" color="action" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(bookmark.createdAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Launch />}
|
||||
onClick={() => handleNavigateToBookmark(bookmark)}
|
||||
>
|
||||
{t('goTo')}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteBookmark(bookmark)}
|
||||
disabled={deletingIds.has(bookmark.id)}
|
||||
>
|
||||
{deletingIds.has(bookmark.id) ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Delete />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user