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}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user