Files
the-bible/frontend/src/components/BibleReader.tsx
Ryderjj89 4510e6e333 Fix NKJV verse parsing to handle header-style verses
- Updated parseBibleText to detect NKJV verses with ###### 1 Verse format
- Added version-specific parsing for ESV (1. format) vs NKJV (###### format)
- Ensures proper verse number extraction and text display for both versions
- Removes diamond question marks and square artifacts in NKJV text
2025-09-28 13:02:51 -04:00

430 lines
15 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { getChapter, getBook } from '../services/api';
interface BibleReaderProps {
book: string;
chapter: string;
onBack: () => void;
formatBookName: (bookName: string) => string;
user?: any;
onFavoriteChange?: () => void;
version?: string;
}
const BibleReader: React.FC<BibleReaderProps> = ({ book, chapter, onBack, formatBookName, user, onFavoriteChange, version = 'esv' }) => {
const [content, setContent] = useState<string>('');
const [loading, setLoading] = useState(true);
const [chapters, setChapters] = useState<string[]>([]);
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [fontSize, setFontSize] = useState<'small' | 'medium' | 'large'>(() => {
// Load font size preference from localStorage
const saved = localStorage.getItem('fontSize');
return (saved as 'small' | 'medium' | 'large') || 'medium';
});
useEffect(() => {
loadChapter();
loadChapters();
}, [book, chapter]);
// Scroll to verse if specified in URL hash
useEffect(() => {
if (!loading && content) {
// Check if there's a verse hash in the URL
const hash = window.location.hash;
if (hash.startsWith('#verse-')) {
const verseElement = document.getElementById(hash.substring(1));
if (verseElement) {
// Small delay to ensure content is rendered
setTimeout(() => {
verseElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}, 100);
}
}
}
}, [loading, content]);
// Load favorites when user is available
useEffect(() => {
if (user) {
loadFavorites();
}
}, [user, book, chapter]);
const loadFavorites = async () => {
if (!user) return;
try {
const response = await fetch('/api/favorites', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
const favoriteStrings: string[] = data.favorites
.filter((fav: any) => fav.book === book && fav.chapter === chapter && fav.verse_start) // Only verse-level favorites for this chapter
.map((fav: any) => fav.verse_end ? `${fav.verse_start}-${fav.verse_end}` : fav.verse_start.toString());
const verseFavorites = new Set<string>(favoriteStrings);
setFavorites(verseFavorites);
console.log('Loaded verse favorites:', favoriteStrings);
}
} catch (error) {
console.error('Failed to load favorites:', error);
}
};
const toggleFavorite = async (verseNumber: string) => {
if (!user) return;
const isFavorited = favorites.has(verseNumber);
try {
if (isFavorited) {
// Remove favorite
const response = await fetch('/api/favorites', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
const verseFavorite = data.favorites.find((fav: any) =>
fav.book === book &&
fav.chapter === chapter &&
fav.verse_start === parseInt(verseNumber)
);
if (verseFavorite) {
const deleteResponse = await fetch(`/api/favorites/${verseFavorite.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (deleteResponse.ok) {
setFavorites(prev => {
const newFavorites = new Set(prev);
newFavorites.delete(verseNumber);
return newFavorites;
});
console.log('Removed verse favorite:', verseNumber);
onFavoriteChange?.(); // Notify parent to refresh favorites menu
}
}
}
} else {
// Add favorite
const response = await fetch('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
book: book,
chapter: chapter,
verse_start: parseInt(verseNumber),
version: version
})
});
if (response.ok) {
setFavorites(prev => new Set(prev).add(verseNumber));
console.log('Added verse favorite:', verseNumber);
onFavoriteChange?.(); // Notify parent to refresh favorites menu
} else if (response.status === 409) {
// 409 means it already exists, which is fine - just update the UI
setFavorites(prev => new Set(prev).add(verseNumber));
console.log('Verse favorite already exists, updated UI:', verseNumber);
onFavoriteChange?.(); // Notify parent to refresh favorites menu
}
}
} catch (error) {
console.error('Failed to toggle favorite:', error);
}
};
const loadChapters = async () => {
try {
const response = await fetch(`/books/${book}?version=${version}`);
const data = await response.json();
if (data.chapters) {
setChapters(data.chapters);
}
} catch (error) {
console.error('Failed to load chapters:', error);
}
};
const getCurrentChapterIndex = () => {
return chapters.findIndex((ch: string) => ch === chapter);
};
const getPreviousChapter = () => {
const currentIndex = getCurrentChapterIndex();
return currentIndex > 0 ? chapters[currentIndex - 1] : null;
};
const getNextChapter = () => {
const currentIndex = getCurrentChapterIndex();
return currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : null;
};
const handlePreviousChapter = () => {
const prevChapter = getPreviousChapter();
if (prevChapter) {
// Navigate to previous chapter - this would need to be passed from parent
window.location.href = window.location.href.replace(`/chapter/${chapter}`, `/chapter/${prevChapter}`);
}
};
const handleNextChapter = () => {
const nextChapter = getNextChapter();
if (nextChapter) {
// Navigate to next chapter - this would need to be passed from parent
window.location.href = window.location.href.replace(`/chapter/${chapter}`, `/chapter/${nextChapter}`);
}
};
useEffect(() => {
// Save font size preference to localStorage
localStorage.setItem('fontSize', fontSize);
}, [fontSize]);
const handleFontSizeChange = (newSize: 'small' | 'medium' | 'large') => {
console.log(`Changing font size from ${fontSize} to ${newSize}`);
setFontSize(newSize);
};
const loadChapter = async () => {
try {
setLoading(true);
console.log(`Loading chapter: ${book}/${chapter} (version: ${version})`);
// Use the chapter number directly as the API handles formatting
const chapterFileName = chapter;
const response = await fetch(`/books/${book}/${chapter}?version=${version}`);
const chapterContent = await response.text();
setContent(chapterContent);
} catch (error) {
console.error('Failed to load chapter:', error);
setContent('Error loading chapter. Please try again.');
} finally {
setLoading(false);
}
};
const getFontSizeClass = () => {
switch (fontSize) {
case 'small':
return 'font-size-small';
case 'large':
return 'font-size-large';
default:
return 'font-size-medium';
}
};
const parseBibleText = (text: string, version: string) => {
const lines = text.split('\n');
const verses: JSX.Element[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip empty lines
if (!line) continue;
let verseNumber: string | null = null;
let verseText: string | null = null;
// Check for different verse formats based on version
if (version === 'nkjv') {
// NKJV: ###### 1 In the beginning God created...
const nkjvMatch = line.match(/^#+\s+(\d+)\s+(.+)$/);
if (nkjvMatch) {
verseNumber = nkjvMatch[1];
verseText = nkjvMatch[2];
}
} else {
// ESV: 1. In the beginning, God created the heavens and the earth.
const esvMatch = line.match(/^(\d+)\.\s+(.+)$/);
if (esvMatch) {
verseNumber = esvMatch[1];
verseText = esvMatch[2];
}
}
if (verseNumber && verseText) {
verses.push(
<div key={`verse-${verseNumber}`} id={`verse-${verseNumber}`} className="mb-4 flex items-start p-2 rounded transition-colors">
<div className="flex-1">
<span className="verse-number font-semibold text-blue-600 dark:text-blue-400 mr-2">{verseNumber}</span>
<span className="bible-text">{verseText}</span>
</div>
{/* Star button - only show for authenticated users */}
{user && (
<button
onClick={() => toggleFavorite(verseNumber)}
className="ml-2 mt-1 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title={favorites.has(verseNumber) ? 'Remove from favorites' : 'Add to favorites'}
>
<Star
className={`h-4 w-4 ${
favorites.has(verseNumber)
? 'text-yellow-500 fill-yellow-500'
: 'text-gray-400 hover:text-yellow-500'
} transition-colors`}
/>
</button>
)}
</div>
);
} else if (line.startsWith('#')) {
// Chapter header - skip full header lines for NKJV as they're part of the title
if (version === 'nkjv' && line.match(/^#+\s+\d+\s+.+/)) {
// This is already handled above as verses
continue;
}
const headerText = line.replace(/^#+\s*/, '');
verses.push(
<h2 key={`header-${i}`} className="chapter-title text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6 mt-8">
{headerText}
</h2>
);
} else {
// Regular text (continuation of previous verse or other content)
verses.push(
<p key={`text-${i}`} className="bible-text mb-4 text-gray-700 dark:text-gray-300">
{line}
</p>
);
}
}
return verses;
};
if (loading) {
return (
<div className="text-center py-12">
<BookOpen className="mx-auto h-12 w-12 text-blue-600 animate-pulse mb-4" />
<p className="text-lg text-gray-600 dark:text-gray-400">Loading chapter...</p>
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 space-y-4 sm:space-y-0">
<button
onClick={onBack}
className="flex items-center space-x-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
>
<ArrowLeft className="h-5 w-5" />
<span className="hidden sm:inline">Back to Chapters</span>
<span className="sm:hidden">Back</span>
</button>
{/* Font Size Controls */}
<div className="flex items-center space-x-2">
<span className="hidden sm:inline text-sm text-gray-600 dark:text-gray-400">Font Size:</span>
<span className="sm:hidden text-xs text-gray-600 dark:text-gray-400">Size:</span>
<div className="flex space-x-1">
<button
onClick={() => handleFontSizeChange('small')}
className={`px-2 sm:px-3 py-1 text-xs rounded ${
fontSize === 'small'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
} transition-colors`}
>
<span className="hidden sm:inline">Small</span>
<span className="sm:hidden">S</span>
</button>
<button
onClick={() => handleFontSizeChange('medium')}
className={`px-2 sm:px-3 py-1 text-xs rounded ${
fontSize === 'medium'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
} transition-colors`}
>
<span className="hidden sm:inline">Medium</span>
<span className="sm:hidden">M</span>
</button>
<button
onClick={() => handleFontSizeChange('large')}
className={`px-2 sm:px-3 py-1 text-xs rounded ${
fontSize === 'large'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
} transition-colors`}
>
<span className="hidden sm:inline">Large</span>
<span className="sm:hidden">L</span>
</button>
</div>
</div>
</div>
{/* Chapter Title */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
{formatBookName(book)} {chapter}
</h1>
{user && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Click the next to verses to add them to your favorites
</p>
)}
</div>
{/* Bible Content */}
<div className={`max-w-3xl mx-auto ${getFontSizeClass()}`}>
{parseBibleText(content, version)}
</div>
{/* Footer Navigation */}
<div className="flex justify-center items-center mt-12 space-x-4">
{/* Previous Chapter Button */}
{getPreviousChapter() && (
<button
onClick={handlePreviousChapter}
className="flex items-center space-x-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</button>
)}
{/* Back to Chapters Button */}
<button
onClick={onBack}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Chapters
</button>
{/* Next Chapter Button */}
{getNextChapter() && (
<button
onClick={handleNextChapter}
className="flex items-center space-x-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div>
);
};
export default BibleReader;