Files
the-bible/frontend/src/App.tsx
Ryderjj89 ac45350ff5 Add version-specific logos to app header and version selector cards
- Header shows ESV/NKJV logo when version is selected, generic book icon on homepage
- Version selector cards now display actual ESV and NKJV logos instead of generic icons
- Logos sourced from frontend/logos/ directory (esv-logo.png, nkjv-logo.png)
- Proper sizing and accessibility with alt text
- Maintains responsive design across all screen sizes
2025-09-28 14:11:30 -04:00

577 lines
19 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Routes, Route, useNavigate, useParams, useLocation } from 'react-router-dom';
import { Book, ChevronRight, Moon, Sun, LogOut, Search } from 'lucide-react';
import BookSelector from './components/BookSelector';
import ChapterSelector from './components/ChapterSelector';
import BibleReader from './components/BibleReader';
import FavoritesMenu from './components/FavoritesMenu';
import SearchComponent from './components/SearchComponent';
import VersionSelector from './components/VersionSelector';
import { getBooks } from './services/api';
interface BookData {
books: string[];
}
function App() {
const [books, setBooks] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<any>(null);
const [authAvailable, setAuthAvailable] = useState(false);
const [darkMode, setDarkMode] = useState(() => {
// Load dark mode preference from localStorage as fallback
const saved = localStorage.getItem('darkMode');
return saved ? JSON.parse(saved) : false;
});
const [error, setError] = useState<string>('');
const [showSearch, setShowSearch] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<string>(''); // Empty means no version selected yet
const [versionSelected, setVersionSelected] = useState(false); // Track if version has been chosen
const [availableVersions, setAvailableVersions] = useState<any[]>([]);
const location = useLocation();
const navigate = useNavigate();
// Debug logging
console.log('App component rendered');
// Read version from URL on mount
useEffect(() => {
const urlParams = new URLSearchParams(location.search);
const versionParam = urlParams.get('version');
if (versionParam && (versionParam === 'esv' || versionParam === 'nkjv')) {
setSelectedVersion(versionParam);
}
}, []);
useEffect(() => {
console.log('App useEffect triggered');
loadVersions();
loadBooks();
checkAuthStatus();
}, []);
// Load versions when version changes
useEffect(() => {
loadBooks();
// Update URL with version when it changes
const url = new URL(window.location.href);
if (selectedVersion !== 'esv') { // Only add if not default
url.searchParams.set('version', selectedVersion);
} else {
url.searchParams.delete('version');
}
window.history.replaceState({}, '', url.toString());
}, [selectedVersion]);
// Load user preferences when user changes
useEffect(() => {
if (user) {
loadUserPreferences();
}
}, [user]);
// Load user preferences from database
const loadUserPreferences = async () => {
if (!user) return;
try {
const response = await fetch('/api/preferences', {
credentials: 'include'
});
if (response.ok) {
const preferences = await response.json();
console.log('Loaded user preferences:', preferences);
setDarkMode(preferences.dark_mode);
}
} catch (error) {
console.error('Failed to load user preferences:', error);
}
};
// Save user preferences to database
const saveUserPreferences = async (newDarkMode: boolean) => {
if (!user) return;
try {
await fetch('/api/preferences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
dark_mode: newDarkMode
})
});
console.log('Saved user preferences to database');
} catch (error) {
console.error('Failed to save user preferences:', error);
}
};
const checkAuthStatus = async () => {
try {
const response = await fetch('/auth/user', {
credentials: 'include'
});
if (response.ok) {
const userData = await response.json();
setUser(userData.user);
setAuthAvailable(true);
} else if (response.status === 501) {
// Authentication not configured
setAuthAvailable(false);
} else if (response.status === 401) {
// Authentication configured but user not logged in
setAuthAvailable(true);
setUser(null);
} else {
// Other error
setAuthAvailable(false);
}
} catch (error) {
console.log('Auth check failed:', error);
setAuthAvailable(false);
}
};
const handleLogin = () => {
window.location.href = '/auth/login';
};
const handleLogout = async () => {
try {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include'
});
setUser(null);
// Optionally reload the page to reset any user-specific state
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
}
};
// Handle dark mode toggle with hybrid storage
const handleDarkModeToggle = async () => {
const newDarkMode = !darkMode;
setDarkMode(newDarkMode);
if (user) {
// Save to database for authenticated users
await saveUserPreferences(newDarkMode);
} else {
// Save to localStorage for non-authenticated users
localStorage.setItem('darkMode', JSON.stringify(newDarkMode));
}
};
useEffect(() => {
// Apply dark mode
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Save to localStorage as backup (for non-authenticated users)
if (!user) {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}
}, [darkMode, user]);
// Helper function to format book names for display
const formatBookName = (bookName: string): string => {
// Handle both formats: numbered (01_Genesis) and regular (Genesis)
const cleanName = bookName.replace(/^\d+_/, ''); // Remove leading number and underscore
return cleanName.replace(/_/g, ' '); // Replace underscores with spaces
};
// Helper function to convert display name back to file name
const getBookFileName = (displayName: string): string => {
// Find the book that matches the display name
const book = books.find((b: string) => formatBookName(b) === displayName);
return book || displayName;
};
// Helper function to convert book file name to URL-safe name
const getBookUrlName = (bookName: string): string => {
// Remove leading numbers and convert spaces to underscores for URL
return bookName.replace(/^\d+_/, '').replace(/ /g, '_');
};
// Helper function to convert URL name back to file name
const getBookFromUrl = (urlName: string): string => {
// Convert URL name back to display name, then find the file name
const displayName = urlName.replace(/_/g, ' ');
return getBookFileName(displayName);
};
const loadBooks = async () => {
try {
console.log('Loading books from API for version:', selectedVersion);
const response = await fetch(`/books?version=${selectedVersion}`);
const data: BookData = await response.json();
console.log('Books loaded:', data);
setBooks(data.books);
} catch (error) {
console.error('Failed to load books:', error);
setError('Failed to load books. Please check the console for details.');
} finally {
setLoading(false);
}
};
const loadVersions = async () => {
try {
console.log('Loading available versions...');
const response = await fetch('/versions');
if (response.ok) {
const data = await response.json();
setAvailableVersions(data.versions);
console.log('Versions loaded:', data.versions);
} else {
console.error('Failed to load versions');
}
} catch (error) {
console.error('Failed to load versions:', error);
}
};
// Get current navigation info from URL
const getCurrentNavInfo = () => {
const pathParts = location.pathname.split('/').filter(Boolean);
if (pathParts.length === 0) {
return { currentBook: null, currentChapter: null };
}
if (pathParts[0] === 'book' && pathParts[1]) {
const currentBook = pathParts[1];
const currentChapter = pathParts[2] === 'chapter' && pathParts[3] ? pathParts[3] : null;
return { currentBook, currentChapter };
}
return { currentBook: null, currentChapter: null };
};
const { currentBook, currentChapter } = getCurrentNavInfo();
// Component for the home page
const HomePage = () => {
const handleVersionSelect = (version: 'esv' | 'nkjv') => {
setSelectedVersion(version);
setVersionSelected(true);
};
const handleBookSelect = (book: string) => {
const urlName = getBookUrlName(book);
navigate(`/book/${urlName}`);
};
const handleFavoriteChange = () => {
// This will trigger a re-render of the FavoritesMenu
setUser((prev: any) => ({ ...prev }));
};
// Show version selector if no version is selected
if (!selectedVersion) {
return (
<VersionSelector onVersionSelect={handleVersionSelect} />
);
}
// Show book selector once version is selected
return (
<BookSelector
books={books}
onBookSelect={handleBookSelect}
formatBookName={formatBookName}
user={user}
onFavoriteChange={handleFavoriteChange}
/>
);
};
// Component for book chapters page
const BookPage = () => {
const { bookName } = useParams<{ bookName: string }>();
const actualBookName = bookName ? getBookFromUrl(bookName) : '';
const handleChapterSelect = (chapter: string) => {
navigate(`/book/${bookName}/chapter/${chapter}`);
};
const handleBack = () => {
navigate('/');
};
const handleFavoriteChange = () => {
// This will trigger a re-render of the FavoritesMenu
setUser((prev: any) => ({ ...prev }));
};
if (!bookName || !actualBookName || !books.includes(actualBookName)) {
return <div>Book not found</div>;
}
return (
<ChapterSelector
book={actualBookName}
onChapterSelect={handleChapterSelect}
onBack={handleBack}
formatBookName={formatBookName}
user={user}
onFavoriteChange={handleFavoriteChange}
/>
);
};
// Component for chapter reading page
const ChapterPage = () => {
const { bookName, chapterNumber } = useParams<{ bookName: string, chapterNumber: string }>();
const actualBookName = bookName ? getBookFromUrl(bookName) : '';
const handleBack = () => {
navigate(`/book/${bookName}`);
};
const handleFavoriteChange = () => {
// This will trigger a re-render of the FavoritesMenu
setUser((prev: any) => ({ ...prev }));
};
if (!bookName || !chapterNumber || !actualBookName) {
return <div>Chapter not found</div>;
}
return (
<BibleReader
book={actualBookName}
chapter={chapterNumber}
onBack={handleBack}
formatBookName={formatBookName}
user={user}
onFavoriteChange={handleFavoriteChange}
version={selectedVersion}
/>
);
};
if (loading || !books) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<Book className="mx-auto h-12 w-12 text-blue-600 animate-pulse" />
<p className="mt-4 text-lg">Loading The Bible...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-2 sm:space-x-4">
{!selectedVersion ? (
<Book className="h-6 w-6 sm:h-8 sm:w-8 text-blue-600" />
) : (
<div className="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center">
<img
src={`/logos/${selectedVersion}-logo.png`}
alt={`${selectedVersion.toUpperCase()} Logo`}
className="max-w-full max-h-full"
/>
</div>
)}
<div className="flex flex-col">
<button
onClick={() => {
setSelectedVersion(''); // Clear version selection to return to version selector
navigate('/');
}}
className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400"
>
The Bible
</button>
{/* Version Selector */}
{selectedVersion ? (
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="text-xs text-gray-600 dark:text-gray-400 bg-transparent border-none focus:outline-none cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
>
{availableVersions.map((version) => (
<option key={version.id} value={version.id}>
{version.name}
</option>
))}
</select>
) : (
<span className="text-xs text-gray-500 dark:text-gray-500 italic">
Choose version
</span>
)}
</div>
</div>
{/* Navigation Breadcrumb - Hidden on small screens */}
<div className="hidden md:flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
{currentBook && (
<>
<button
onClick={() => navigate('/')}
className="hover:text-blue-600 dark:hover:text-blue-400"
>
Books
</button>
<ChevronRight className="h-4 w-4" />
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatBookName(currentBook)}
</span>
{currentChapter && (
<>
<ChevronRight className="h-4 w-4" />
<button
onClick={() => navigate(`/book/${currentBook}`)}
className="hover:text-blue-600 dark:hover:text-blue-400"
>
Chapters
</button>
<ChevronRight className="h-4 w-4" />
<span className="font-medium text-gray-900 dark:text-gray-100">
Chapter {currentChapter}
</span>
</>
)}
</>
)}
</div>
{/* Mobile Navigation - Simplified */}
<div className="flex md:hidden items-center space-x-1 text-xs text-gray-600 dark:text-gray-400">
{currentBook && (
<>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatBookName(currentBook)}
</span>
{currentChapter && (
<>
<span></span>
<span className="font-medium text-gray-900 dark:text-gray-100">
Ch. {currentChapter}
</span>
</>
)}
</>
)}
</div>
{/* Search, User Authentication & Dark Mode */}
<div className="flex items-center space-x-2">
{/* Search Button */}
<button
onClick={() => setShowSearch(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Search Bible"
>
<Search className="h-4 w-4 sm:h-5 sm:w-5 text-gray-600 dark:text-gray-400" />
</button>
{/* Authentication Button */}
{authAvailable && (
<div>
{user ? (
<div className="flex items-center space-x-2">
<span className="hidden sm:inline text-sm text-gray-600 dark:text-gray-400">
{user.name || user.email}
</span>
<button
onClick={handleLogout}
className="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<span className="hidden sm:inline">Logout</span>
<LogOut className="sm:hidden h-4 w-4" />
</button>
</div>
) : (
<button
onClick={handleLogin}
className="text-sm px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
<span className="hidden sm:inline">Login</span>
<span className="sm:hidden">👤</span>
</button>
)}
</div>
)}
{/* Dark Mode Toggle */}
<button
onClick={handleDarkModeToggle}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
{darkMode ? (
<Sun className="h-4 w-4 sm:h-5 sm:w-5 text-yellow-500" />
) : (
<Moon className="h-4 w-4 sm:h-5 sm:w-5 text-gray-600" />
)}
</button>
</div>
</div>
</div>
</header>
{/* Favorites Menu - Positioned below header for authenticated users */}
{user && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
<div className="flex justify-end">
<FavoritesMenu
user={user}
formatBookName={formatBookName}
getBookUrlName={getBookUrlName}
setSelectedVersion={setSelectedVersion}
onFavoriteChange={() => setUser((prev: any) => ({ ...prev }))}
/>
</div>
</div>
)}
{/* Search Modal */}
{showSearch && (
<SearchComponent
formatBookName={formatBookName}
getBookUrlName={getBookUrlName}
books={books}
onClose={() => setShowSearch(false)}
isModal={true}
/>
)}
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/book/:bookName" element={<BookPage />} />
<Route path="/book/:bookName/chapter/:chapterNumber" element={<ChapterPage />} />
<Route path="/search" element={
<SearchComponent
formatBookName={formatBookName}
getBookUrlName={getBookUrlName}
books={books}
isModal={false}
/>
} />
</Routes>
</main>
</div>
);
}
export default App;