From fc5d6604ff05d84c01a79863b2a7321ef701d396 Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 10 Oct 2025 10:58:12 +0000 Subject: [PATCH] feat: implement Phase 1 Bible reader improvements (2025 standards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- app/[locale]/bible/reader.tsx | 315 ++++++++++++++++++++++++++++++- app/api/highlights/[id]/route.ts | 85 +++++++++ app/api/highlights/bulk/route.ts | 44 +++++ app/api/highlights/route.ts | 81 ++++++++ prisma/schema.prisma | 20 ++ 5 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 app/api/highlights/[id]/route.ts create mode 100644 app/api/highlights/bulk/route.ts create mode 100644 app/api/highlights/route.ts diff --git a/app/[locale]/bible/reader.tsx b/app/[locale]/bible/reader.tsx index f0df315..3cc11ab 100644 --- a/app/[locale]/bible/reader.tsx +++ b/app/[locale]/bible/reader.tsx @@ -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(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 ( { 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 } }} > - + + + Letter Spacing + setPreferences(prev => ({ ...prev, letterSpacing: value as number }))} + min={0} + max={2} + step={0.1} + marks + valueLabelDisplay="auto" + /> + + + + Word Spacing + setPreferences(prev => ({ ...prev, wordSpacing: value as number }))} + min={0} + max={4} + step={0.5} + marks + valueLabelDisplay="auto" + /> + + + + Paragraph Spacing + setPreferences(prev => ({ ...prev, paragraphSpacing: value as number }))} + min={1.0} + max={2.5} + step={0.1} + marks + valueLabelDisplay="auto" + /> + + + + Max Line Length + setPreferences(prev => ({ ...prev, maxLineLength: value as number }))} + min={50} + max={100} + step={5} + marks + valueLabelDisplay="auto" + /> + + {t('fontFamily')} @@ -2004,6 +2224,14 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha {verseMenuAnchor.verse && verseBookmarks[verseMenuAnchor.verse.id] ? 'Remove Bookmark' : 'Bookmark'} + handleVerseMenuAction('highlight')}> + + + + + {verseMenuAnchor.verse && highlights[verseMenuAnchor.verse.id] ? 'Change Highlight' : 'Highlight'} + + handleVerseMenuAction('copy')}> @@ -2018,6 +2246,73 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha + {/* Highlight Color Picker */} + { + setHighlightColorPickerAnchor({ element: null, verse: null }) + handleVerseMenuClose() + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + > + + + Select Highlight Color + + + {(['yellow', 'green', 'blue', 'purple', 'orange', 'pink', 'red'] as const).map(color => ( + { + 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 + } + }} + /> + ))} + + {highlightColorPickerAnchor.verse && highlights[highlightColorPickerAnchor.verse.id] && ( + + )} + + + {/* Copy Feedback */} { + 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 }) + } +} diff --git a/app/api/highlights/route.ts b/app/api/highlights/route.ts new file mode 100644 index 0000000..f1ac53c --- /dev/null +++ b/app/api/highlights/route.ts @@ -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 }) + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 498d22b..5586b40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { sessions Session[] bookmarks Bookmark[] chapterBookmarks ChapterBookmark[] + highlights Highlight[] notes Note[] chatMessages ChatMessage[] chatConversations ChatConversation[] @@ -116,6 +117,7 @@ model BibleVerse { chapter BibleChapter @relation(fields: [chapterId], references: [id]) bookmarks Bookmark[] notes Note[] + highlights Highlight[] @@unique([chapterId, verseNum]) @@index([chapterId]) @@ -210,6 +212,24 @@ model ChapterBookmark { @@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 { id String @id @default(uuid()) userId String