Files
biblical-guide.com/app/[locale]/bookmarks/page.tsx
Andrei 61a5180e2f feat: implement SEO-friendly URLs for Bible reader
- Add dynamic route structure /[locale]/bible/[version]/[book]/[chapter]
- Convert UUID-based URLs to readable format (e.g., /en/bible/eng-kjv/genesis/1)
- Implement automatic redirects from old URLs to new SEO-friendly format
- Add SEO metadata generation with proper titles, descriptions, and OpenGraph tags
- Create API endpoint for URL conversion between formats
- Update navigation in search results, bookmarks, and internal links
- Fix PWA manifest icons to correct dimensions (192x192, 512x512)
- Resolve JavaScript parameter passing issues between server and client components
- Maintain backward compatibility with existing bookmark and search functionality

Benefits:
- Improved SEO with descriptive URLs
- Better user experience with readable URLs
- Enhanced social media sharing
- Maintained full backward compatibility
2025-09-28 23:17:58 +00:00

434 lines
14 KiB
TypeScript

'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,
IconButton,
Alert,
CircularProgress,
Tab,
Tabs,
List,
ListItem,
ListItemIcon,
ListItemText,
} 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
versionId: string
}
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 = async (bookmark: BookmarkItem) => {
try {
// Try to generate SEO-friendly URL
const response = await fetch('/api/bible/seo-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
versionId: bookmark.navigation.versionId,
bookId: bookmark.navigation.bookId,
chapter: bookmark.navigation.chapterNum.toString(),
locale
})
})
if (response.ok) {
const data = await response.json()
if (data.success && data.seoUrl) {
let url = data.seoUrl
if (bookmark.navigation.verseNum) {
url += `?verse=${bookmark.navigation.verseNum}`
}
router.push(url)
return
}
}
} catch (error) {
console.error('Error generating SEO URL:', error)
}
// Fallback to old URL format
const params = new URLSearchParams({
book: bookmark.navigation.bookId,
chapter: bookmark.navigation.chapterNum.toString(),
version: bookmark.navigation.versionId
})
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 */}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap', mb: 4 }}>
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(33.33% - 16px)' } }}>
<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>
</Box>
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(33.33% - 16px)' } }}>
<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>
</Box>
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(33.33% - 16px)' } }}>
<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>
</Box>
</Box>
<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>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{filteredBookmarks().map((bookmark) => (
<Box 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>
</Box>
))}
</Box>
)}
</Paper>
</Container>
</ProtectedRoute>
)
}