diff --git a/backend/src/database.js b/backend/src/database.js index 69e86340..99f1c7c8 100644 --- a/backend/src/database.js +++ b/backend/src/database.js @@ -65,6 +65,21 @@ function initializeTables() { } }); + // Favorite sections table + db.run(` + CREATE TABLE IF NOT EXISTS favorite_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + color TEXT DEFAULT '#6B7280', + sort_order INTEGER DEFAULT 0, + is_default BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + UNIQUE(user_id, name) + ) + `); + // Favorites table (with IF NOT EXISTS for safety) db.run(` CREATE TABLE IF NOT EXISTS favorites ( @@ -76,12 +91,28 @@ function initializeTables() { verse_end INTEGER, version TEXT DEFAULT 'esv', note TEXT, + section_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (section_id) REFERENCES favorite_sections (id) ON DELETE SET NULL, UNIQUE(user_id, book, chapter, verse_start, verse_end, version) ) `); + // Migration: Add section_id column to existing favorites table if it doesn't exist + db.all("PRAGMA table_info(favorites)", [], (err, columns) => { + if (!err && columns.length > 0) { + const hasSectionIdColumn = columns.some(col => col.name === 'section_id'); + if (!hasSectionIdColumn) { + db.run("ALTER TABLE favorites ADD COLUMN section_id INTEGER REFERENCES favorite_sections(id) ON DELETE SET NULL", (err) => { + if (!err) { + console.log('Added section_id column to favorites table'); + } + }); + } + } + }); + console.log('Database tables initialized'); } @@ -171,12 +202,126 @@ const preferencesOps = { } }; +// Sections operations +const sectionsOps = { + // Get all sections for user + getSections: (userId, callback) => { + db.all( + 'SELECT * FROM favorite_sections WHERE user_id = ? ORDER BY sort_order ASC, created_at ASC', + [userId], + callback + ); + }, + + // Create a new section + createSection: (userId, section, callback) => { + const { name, color } = section; + + // Get the next sort_order + db.get( + 'SELECT COALESCE(MAX(sort_order), -1) + 1 as next_order FROM favorite_sections WHERE user_id = ?', + [userId], + (err, row) => { + if (err) return callback(err); + + db.run( + `INSERT INTO favorite_sections (user_id, name, color, sort_order) + VALUES (?, ?, ?, ?)`, + [userId, name, color || '#6B7280', row.next_order], + function(err) { + if (err) return callback(err); + callback(null, { id: this.lastID, name, color: color || '#6B7280', sort_order: row.next_order }); + } + ); + } + ); + }, + + // Update a section + updateSection: (userId, sectionId, updates, callback) => { + const { name, color, is_default } = updates; + + // If setting as default, first unset any existing default + if (is_default) { + db.run( + 'UPDATE favorite_sections SET is_default = 0 WHERE user_id = ?', + [userId], + (err) => { + if (err) return callback(err); + + db.run( + `UPDATE favorite_sections + SET name = COALESCE(?, name), + color = COALESCE(?, color), + is_default = ? + WHERE id = ? AND user_id = ?`, + [name, color, is_default ? 1 : 0, sectionId, userId], + callback + ); + } + ); + } else { + db.run( + `UPDATE favorite_sections + SET name = COALESCE(?, name), + color = COALESCE(?, color), + is_default = COALESCE(?, is_default) + WHERE id = ? AND user_id = ?`, + [name, color, is_default !== undefined ? (is_default ? 1 : 0) : null, sectionId, userId], + callback + ); + } + }, + + // Delete a section (favorites will have section_id set to NULL via ON DELETE SET NULL) + deleteSection: (userId, sectionId, callback) => { + db.run( + 'DELETE FROM favorite_sections WHERE id = ? AND user_id = ?', + [sectionId, userId], + callback + ); + }, + + // Reorder sections + reorderSections: (userId, sectionIds, callback) => { + const updates = sectionIds.map((id, index) => { + return new Promise((resolve, reject) => { + db.run( + 'UPDATE favorite_sections SET sort_order = ? WHERE id = ? AND user_id = ?', + [index, id, userId], + (err) => { + if (err) reject(err); + else resolve(); + } + ); + }); + }); + + Promise.all(updates) + .then(() => callback(null)) + .catch(callback); + }, + + // Get default section for user + getDefaultSection: (userId, callback) => { + db.get( + 'SELECT * FROM favorite_sections WHERE user_id = ? AND is_default = 1', + [userId], + callback + ); + } +}; + // Favorites operations const favoritesOps = { - // Get user favorites + // Get user favorites with section info getFavorites: (userId, callback) => { db.all( - 'SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC', + `SELECT f.*, s.name as section_name, s.color as section_color + FROM favorites f + LEFT JOIN favorite_sections s ON f.section_id = s.id + WHERE f.user_id = ? + ORDER BY f.created_at DESC`, [userId], callback ); @@ -184,12 +329,12 @@ const favoritesOps = { // Add favorite addFavorite: (userId, favorite, callback) => { - const { book, chapter, verse_start, verse_end, version, note } = favorite; + const { book, chapter, verse_start, verse_end, version, note, section_id } = favorite; db.run( - `INSERT INTO favorites (user_id, book, chapter, verse_start, verse_end, version, note) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [userId, book, chapter, verse_start, verse_end, version || 'esv', note], + `INSERT INTO favorites (user_id, book, chapter, verse_start, verse_end, version, note, section_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, book, chapter, verse_start, verse_end, version || 'esv', note, section_id || null], callback ); }, @@ -203,6 +348,15 @@ const favoritesOps = { ); }, + // Update favorite's section + updateFavoriteSection: (userId, favoriteId, sectionId, callback) => { + db.run( + 'UPDATE favorites SET section_id = ? WHERE id = ? AND user_id = ?', + [sectionId, favoriteId, userId], + callback + ); + }, + // Check if verse is favorited isFavorited: (userId, book, chapter, verse_start, verse_end, version, callback) => { db.get( @@ -220,5 +374,6 @@ module.exports = { db, userOps, preferencesOps, - favoritesOps + favoritesOps, + sectionsOps }; diff --git a/backend/src/index.js b/backend/src/index.js index 58a5cc25..81b7ba5a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -4,7 +4,7 @@ const helmet = require('helmet'); const path = require('path'); const fs = require('fs').promises; const { configureAuth, requireAuth, optionalAuth } = require('./auth'); -const { preferencesOps, favoritesOps } = require('./database'); +const { preferencesOps, favoritesOps, sectionsOps } = require('./database'); const SearchDatabase = require('./searchDatabase'); const app = express(); @@ -384,7 +384,7 @@ app.get('/api/favorites', requireAuth, (req, res) => { }); app.post('/api/favorites', requireAuth, (req, res) => { - const { book, chapter, verse_start, verse_end, note, version } = req.body; + const { book, chapter, verse_start, verse_end, note, version, section_id } = req.body; // Book is required, but chapter is optional (for book-level favorites) if (!book) { @@ -397,7 +397,8 @@ app.post('/api/favorites', requireAuth, (req, res) => { verse_start: verse_start || null, verse_end: verse_end || null, version: version || 'esv', - note: note || null + note: note || null, + section_id: section_id || null }; favoritesOps.addFavorite(req.user.id, favorite, function(err) { @@ -407,7 +408,7 @@ app.post('/api/favorites', requireAuth, (req, res) => { } return res.status(500).json({ error: 'Failed to add favorite' }); } - res.json({ + res.json({ message: 'Favorite added successfully', id: this.lastID }); @@ -427,16 +428,16 @@ app.delete('/api/favorites/:id', requireAuth, (req, res) => { app.get('/api/favorites/check', requireAuth, (req, res) => { const { book, chapter, verse_start, verse_end } = req.query; - + if (!book) { return res.status(400).json({ error: 'Book is required' }); } favoritesOps.isFavorited( - req.user.id, - book, - chapter || null, - verse_start || null, + req.user.id, + book, + chapter || null, + verse_start || null, verse_end || null, (err, isFavorited) => { if (err) { @@ -447,6 +448,91 @@ app.get('/api/favorites/check', requireAuth, (req, res) => { ); }); +// Update favorite's section +app.put('/api/favorites/:id/section', requireAuth, (req, res) => { + const favoriteId = req.params.id; + const { section_id } = req.body; + + favoritesOps.updateFavoriteSection(req.user.id, favoriteId, section_id, (err) => { + if (err) { + return res.status(500).json({ error: 'Failed to update favorite section' }); + } + res.json({ message: 'Favorite section updated successfully' }); + }); +}); + +// Sections routes +app.get('/api/sections', requireAuth, (req, res) => { + sectionsOps.getSections(req.user.id, (err, sections) => { + if (err) { + return res.status(500).json({ error: 'Failed to get sections' }); + } + res.json({ sections }); + }); +}); + +app.post('/api/sections', requireAuth, (req, res) => { + const { name, color } = req.body; + + if (!name || !name.trim()) { + return res.status(400).json({ error: 'Section name is required' }); + } + + sectionsOps.createSection(req.user.id, { name: name.trim(), color }, (err, section) => { + if (err) { + if (err.code === 'SQLITE_CONSTRAINT') { + return res.status(409).json({ error: 'A section with this name already exists' }); + } + return res.status(500).json({ error: 'Failed to create section' }); + } + res.json({ + message: 'Section created successfully', + section + }); + }); +}); + +app.put('/api/sections/:id', requireAuth, (req, res) => { + const sectionId = req.params.id; + const { name, color, is_default } = req.body; + + sectionsOps.updateSection(req.user.id, sectionId, { name, color, is_default }, (err) => { + if (err) { + if (err.code === 'SQLITE_CONSTRAINT') { + return res.status(409).json({ error: 'A section with this name already exists' }); + } + return res.status(500).json({ error: 'Failed to update section' }); + } + res.json({ message: 'Section updated successfully' }); + }); +}); + +app.delete('/api/sections/:id', requireAuth, (req, res) => { + const sectionId = req.params.id; + + sectionsOps.deleteSection(req.user.id, sectionId, (err) => { + if (err) { + return res.status(500).json({ error: 'Failed to delete section' }); + } + res.json({ message: 'Section deleted successfully' }); + }); +}); + +app.put('/api/sections/reorder', requireAuth, (req, res) => { + const { sectionIds } = req.body; + + if (!Array.isArray(sectionIds)) { + return res.status(400).json({ error: 'sectionIds must be an array' }); + } + + sectionsOps.reorderSections(req.user.id, sectionIds, (err) => { + if (err) { + return res.status(500).json({ error: 'Failed to reorder sections' }); + } + res.json({ message: 'Sections reordered successfully' }); + }); +}); + // Catch-all handler: send back React's index.html for client-side routing app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../../frontend/build/index.html')); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f2d4273..4f46716b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import FavoritesMenu from './components/FavoritesMenu'; import SearchComponent from './components/SearchComponent'; import VersionSelector from './components/VersionSelector'; import { getBooks } from './services/api'; +import { Section } from './types/favorites'; interface BookData { books: string[]; @@ -29,6 +30,7 @@ function App() { const [versionSelected, setVersionSelected] = useState(false); // Track if version has been chosen const [availableVersions, setAvailableVersions] = useState([]); const [favorites, setFavorites] = useState([]); // Centralized favorites state + const [sections, setSections] = useState([]); // Sections for organizing favorites const location = useLocation(); const navigate = useNavigate(); @@ -72,8 +74,10 @@ function App() { if (user) { loadUserPreferences(); loadFavorites(); // Load favorites once when user logs in + loadSections(); // Load sections when user logs in } else { setFavorites([]); // Clear favorites when user logs out + setSections([]); // Clear sections when user logs out } }, [user]); @@ -278,6 +282,34 @@ function App() { } }; + // Load sections for the user + const loadSections = async () => { + if (!user) { + setSections([]); + return; + } + + try { + const response = await fetch('/api/sections', { + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + setSections(data.sections); + console.log('Loaded sections:', data.sections.length); + } + } catch (error) { + console.error('Failed to load sections:', error); + setSections([]); + } + }; + + // Handler for section changes (create, update, delete) + const handleSectionChange = async () => { + await loadSections(); + }; + // Helper functions to filter favorites by type const getBookFavorites = (version: string) => { return new Set( @@ -361,6 +393,8 @@ function App() { onBack={handleBack} onSearchClick={() => setShowSearch(true)} favorites={versionId ? getBookFavorites(versionId) : new Set()} + sections={sections} + onSectionChange={handleSectionChange} /> ); }; @@ -393,6 +427,8 @@ function App() { version={versionId} onSearchClick={() => setShowSearch(true)} favorites={versionId ? getChapterFavorites(actualBookName, versionId) : new Set()} + sections={sections} + onSectionChange={handleSectionChange} /> ); }; @@ -421,6 +457,8 @@ function App() { version={selectedVersion} onSearchClick={() => setShowSearch(true)} favorites={getVerseFavorites(actualBookName, chapterNumber, selectedVersion)} + sections={sections} + onSectionChange={handleSectionChange} /> ); }; @@ -547,6 +585,8 @@ function App() { getBookUrlName={getBookUrlName} setSelectedVersion={setSelectedVersion} onFavoriteChange={handleFavoriteChange} + sections={sections} + onSectionChange={handleSectionChange} /> diff --git a/frontend/src/components/BibleReader.tsx b/frontend/src/components/BibleReader.tsx index 6b9f5c80..eef99c26 100644 --- a/frontend/src/components/BibleReader.tsx +++ b/frontend/src/components/BibleReader.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, memo } from 'react'; import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, Star, Search } from 'lucide-react'; import { getChapter, getBook } from '../services/api'; +import { Section } from '../types/favorites'; +import SectionPicker from './SectionPicker'; interface BibleReaderProps { book: string; @@ -12,6 +14,8 @@ interface BibleReaderProps { version?: string; onSearchClick?: () => void; favorites?: Set; // Favorites passed from parent (centralized state) + sections?: Section[]; + onSectionChange?: () => void; } const BibleReader: React.FC = memo(({ @@ -23,7 +27,9 @@ const BibleReader: React.FC = memo(({ onFavoriteChange, version = 'esv', onSearchClick, - favorites = new Set() // Default to empty set if not provided + favorites = new Set(), // Default to empty set if not provided + sections = [], + onSectionChange }) => { const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); @@ -33,6 +39,8 @@ const BibleReader: React.FC = memo(({ const saved = localStorage.getItem('fontSize'); return (saved as 'small' | 'medium' | 'large') || 'medium'; }); + const [showSectionPicker, setShowSectionPicker] = useState(false); + const [pendingVerse, setPendingVerse] = useState(null); useEffect(() => { loadChapter(); @@ -59,18 +67,18 @@ const BibleReader: React.FC = memo(({ } }, [loading, content]); - const toggleFavorite = async (verseNumber: string) => { + const toggleFavorite = async (verseNumber: string, sectionId?: number | null) => { 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) => @@ -79,7 +87,7 @@ const BibleReader: React.FC = memo(({ fav.verse_start === parseInt(verseNumber) && fav.version === version ); - + if (verseFavorite) { const deleteResponse = await fetch(`/api/favorites/${verseFavorite.id}`, { method: 'DELETE', @@ -88,33 +96,26 @@ const BibleReader: React.FC = memo(({ if (deleteResponse.ok) { console.log('Removed verse favorite:', verseNumber); - onFavoriteChange?.(); // Notify parent to refresh favorites + onFavoriteChange?.(); } } } } 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) { - console.log('Added verse favorite:', verseNumber); - onFavoriteChange?.(); // Notify parent to refresh favorites - } else if (response.status === 409) { - // 409 means it already exists, which is fine - console.log('Verse favorite already exists:', verseNumber); - onFavoriteChange?.(); // Notify parent to refresh favorites + // Add favorite - check if we need section picker + if (sections.length > 0 && sectionId === undefined) { + // User has sections, check for default + const defaultSection = sections.find(s => s.is_default); + if (defaultSection) { + // Use default section automatically + await addFavoriteWithSection(verseNumber, defaultSection.id); + } else { + // Show section picker + setPendingVerse(verseNumber); + setShowSectionPicker(true); + } + } else { + // No sections or section already specified + await addFavoriteWithSection(verseNumber, sectionId ?? null); } } } catch (error) { @@ -122,6 +123,43 @@ const BibleReader: React.FC = memo(({ } }; + const addFavoriteWithSection = async (verseNumber: string, sectionId: number | null) => { + try { + 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, + section_id: sectionId + }) + }); + + if (response.ok) { + console.log('Added verse favorite:', verseNumber, 'to section:', sectionId); + onFavoriteChange?.(); + } else if (response.status === 409) { + console.log('Verse favorite already exists:', verseNumber); + onFavoriteChange?.(); + } + } catch (error) { + console.error('Failed to add favorite:', error); + } + }; + + const handleSectionSelect = async (sectionId: number | null) => { + if (pendingVerse) { + await addFavoriteWithSection(pendingVerse, sectionId); + setPendingVerse(null); + } + setShowSectionPicker(false); + }; + const loadChapters = async () => { try { const response = await fetch(`/books/${book}?version=${version}`); @@ -400,6 +438,19 @@ const BibleReader: React.FC = memo(({ )} + + {/* Section Picker Modal */} + {showSectionPicker && ( + { + setShowSectionPicker(false); + setPendingVerse(null); + }} + onSectionCreated={onSectionChange} + /> + )} ); }); diff --git a/frontend/src/components/BookSelector.tsx b/frontend/src/components/BookSelector.tsx index aa76c476..17b7ca47 100644 --- a/frontend/src/components/BookSelector.tsx +++ b/frontend/src/components/BookSelector.tsx @@ -1,5 +1,7 @@ -import React, { useState, useEffect, memo } from 'react'; -import { BookOpen, Star, ChevronLeft, Search } from 'lucide-react'; +import React, { useState, memo } from 'react'; +import { BookOpen, Star, Search } from 'lucide-react'; +import { Section } from '../types/favorites'; +import SectionPicker from './SectionPicker'; interface BookSelectorProps { books: string[]; @@ -10,7 +12,9 @@ interface BookSelectorProps { version?: string; onBack?: () => void; onSearchClick?: () => void; - favorites?: Set; // Favorites passed from parent (centralized state) + favorites?: Set; + sections?: Section[]; + onSectionChange?: () => void; } const BookSelector: React.FC = memo(({ @@ -22,27 +26,31 @@ const BookSelector: React.FC = memo(({ version = 'esv', onBack, onSearchClick, - favorites = new Set() // Default to empty set if not provided + favorites = new Set(), + sections = [], + onSectionChange }) => { + const [showSectionPicker, setShowSectionPicker] = useState(false); + const [pendingBook, setPendingBook] = useState(null); + + const toggleFavorite = async (book: string, event: React.MouseEvent, sectionId?: number | null) => { + event.stopPropagation(); - const toggleFavorite = async (book: string, event: React.MouseEvent) => { - event.stopPropagation(); // Prevent book selection when clicking star - if (!user) return; const isFavorited = favorites.has(book); - + try { if (isFavorited) { // Remove favorite const response = await fetch('/api/favorites', { credentials: 'include' }); - + if (response.ok) { const data = await response.json(); const bookFavorite = data.favorites.find((fav: any) => fav.book === book && !fav.chapter); - + if (bookFavorite) { const deleteResponse = await fetch(`/api/favorites/${bookFavorite.id}`, { method: 'DELETE', @@ -51,29 +59,22 @@ const BookSelector: React.FC = memo(({ if (deleteResponse.ok) { console.log('Removed book favorite:', book); - onFavoriteChange?.(); // Notify parent to refresh favorites + onFavoriteChange?.(); } } } } else { - // Add favorite - simplified like ChapterSelector - const response = await fetch('/api/favorites', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ - book: book, - version: version - }) - }); - - if (response.ok) { - console.log('Added book favorite:', book); - onFavoriteChange?.(); // Notify parent to refresh favorites + // Add favorite - check if we need section picker + if (sections.length > 0 && sectionId === undefined) { + const defaultSection = sections.find(s => s.is_default); + if (defaultSection) { + await addFavoriteWithSection(book, defaultSection.id); + } else { + setPendingBook(book); + setShowSectionPicker(true); + } } else { - console.error('Failed to add favorite:', response.status, response.statusText); + await addFavoriteWithSection(book, sectionId ?? null); } } } catch (error) { @@ -81,9 +82,43 @@ const BookSelector: React.FC = memo(({ } }; + const addFavoriteWithSection = async (book: string, sectionId: number | null) => { + try { + const response = await fetch('/api/favorites', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + book: book, + version: version, + section_id: sectionId + }) + }); + + if (response.ok) { + console.log('Added book favorite:', book, 'to section:', sectionId); + onFavoriteChange?.(); + } else { + console.error('Failed to add favorite:', response.status, response.statusText); + } + } catch (error) { + console.error('Failed to add favorite:', error); + } + }; + + const handleSectionSelect = async (sectionId: number | null) => { + if (pendingBook) { + await addFavoriteWithSection(pendingBook, sectionId); + setPendingBook(null); + } + setShowSectionPicker(false); + }; + // Group books by testament - const oldTestament = books.slice(0, 39); // First 39 books - const newTestament = books.slice(39); // Remaining books + const oldTestament = books.slice(0, 39); + const newTestament = books.slice(39); const BookGroup: React.FC<{ title: string; books: string[] }> = ({ title, books }) => (
@@ -102,18 +137,17 @@ const BookSelector: React.FC = memo(({ {formatBookName(book)} - - {/* Star button - only show for authenticated users */} + {user && (
+ + {/* Section Picker Modal */} + {showSectionPicker && ( + { + setShowSectionPicker(false); + setPendingBook(null); + }} + onSectionCreated={onSectionChange} + /> + )} ); }); diff --git a/frontend/src/components/ChapterSelector.tsx b/frontend/src/components/ChapterSelector.tsx index b664976a..18d4d57d 100644 --- a/frontend/src/components/ChapterSelector.tsx +++ b/frontend/src/components/ChapterSelector.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, memo } from 'react'; import { ArrowLeft, FileText, Star, ChevronRight, Search } from 'lucide-react'; import { getBook } from '../services/api'; +import { Section } from '../types/favorites'; +import SectionPicker from './SectionPicker'; interface ChapterSelectorProps { book: string; @@ -11,7 +13,9 @@ interface ChapterSelectorProps { onFavoriteChange?: () => void; version?: string; onSearchClick?: () => void; - favorites?: Set; // Favorites passed from parent (centralized state) + favorites?: Set; + sections?: Section[]; + onSectionChange?: () => void; } const ChapterSelector: React.FC = memo(({ @@ -23,35 +27,39 @@ const ChapterSelector: React.FC = memo(({ onFavoriteChange, version = 'esv', onSearchClick, - favorites = new Set() // Default to empty set if not provided + favorites = new Set(), + sections = [], + onSectionChange }) => { const [chapters, setChapters] = useState([]); const [loading, setLoading] = useState(true); + const [showSectionPicker, setShowSectionPicker] = useState(false); + const [pendingChapter, setPendingChapter] = useState(null); useEffect(() => { loadChapters(); }, [book]); - const toggleFavorite = async (chapter: string, event: React.MouseEvent) => { - event.stopPropagation(); // Prevent chapter selection when clicking star - + const toggleFavorite = async (chapter: string, event: React.MouseEvent, sectionId?: number | null) => { + event.stopPropagation(); + if (!user) return; const isFavorited = favorites.has(chapter); - + try { if (isFavorited) { // Remove favorite const response = await fetch('/api/favorites', { credentials: 'include' }); - + if (response.ok) { const data = await response.json(); - const chapterFavorite = data.favorites.find((fav: any) => + const chapterFavorite = data.favorites.find((fav: any) => fav.book === book && fav.chapter === chapter && !fav.verse_start ); - + if (chapterFavorite) { await fetch(`/api/favorites/${chapterFavorite.id}`, { method: 'DELETE', @@ -59,27 +67,21 @@ const ChapterSelector: React.FC = memo(({ }); console.log('Removed chapter favorite:', chapter); - onFavoriteChange?.(); // Notify parent to refresh favorites + onFavoriteChange?.(); } } } 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, - version: version - }) - }); - - if (response.ok) { - console.log('Added chapter favorite:', chapter); - onFavoriteChange?.(); // Notify parent to refresh favorites + // Add favorite - check if we need section picker + if (sections.length > 0 && sectionId === undefined) { + const defaultSection = sections.find(s => s.is_default); + if (defaultSection) { + await addFavoriteWithSection(chapter, defaultSection.id); + } else { + setPendingChapter(chapter); + setShowSectionPicker(true); + } + } else { + await addFavoriteWithSection(chapter, sectionId ?? null); } } } catch (error) { @@ -87,14 +89,45 @@ const ChapterSelector: React.FC = memo(({ } }; + const addFavoriteWithSection = async (chapter: string, sectionId: number | null) => { + try { + const response = await fetch('/api/favorites', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + book: book, + chapter: chapter, + version: version, + section_id: sectionId + }) + }); + + if (response.ok) { + console.log('Added chapter favorite:', chapter, 'to section:', sectionId); + onFavoriteChange?.(); + } + } catch (error) { + console.error('Failed to add favorite:', error); + } + }; + + const handleSectionSelect = async (sectionId: number | null) => { + if (pendingChapter) { + await addFavoriteWithSection(pendingChapter, sectionId); + setPendingChapter(null); + } + setShowSectionPicker(false); + }; + const loadChapters = async () => { try { setLoading(true); const response = await getBook(book, version); - // The API now returns { chapters: ["1", "2", "3", ...] } if (response.chapters) { - // Sort chapters numerically to ensure proper order const sortedChapters = response.chapters.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); setChapters(sortedChapters); } else { @@ -103,7 +136,6 @@ const ChapterSelector: React.FC = memo(({ } } catch (error) { console.error('Failed to load chapters:', error); - // Don't show fallback chapters - just show an empty list setChapters([]); } finally { setLoading(false); @@ -160,7 +192,7 @@ const ChapterSelector: React.FC = memo(({

{user && (

- Click the ★ to add chapters to your favorites + Click the star to add chapters to your favorites

)} @@ -178,18 +210,17 @@ const ChapterSelector: React.FC = memo(({ {chapter} - - {/* Star button - only show for authenticated users */} + {user && ( +
+ {sections.length > 0 && ( +
+ + {movingFavorite === favorite.id && ( +
+
+ + {sections.map(section => ( + + ))} +
+
+ )} +
+ )} + +
+ + ); + + const renderSectionGroup = (group: { key: string; section: Section | null; favorites: Favorite[] }) => { + const isCollapsed = collapsedSections.has(group.key); + const sectionName = group.section?.name || 'Uncategorized'; + const sectionColor = group.section?.color || '#6B7280'; return ( -
-
- {icon} - - {title} ({items.length}) - -
- {items.map((favorite) => ( +
+ - + className="w-3 h-3 rounded-full" + style={{ backgroundColor: sectionColor }} + /> + + {sectionName} + + + {group.favorites.length} + + + {!isCollapsed && group.favorites.length > 0 && ( +
+ {group.favorites.map(renderFavorite)}
- ))} + )}
); }; if (!user) { - return null; // Don't show favorites menu for non-authenticated users + return null; } return ( -
- {/* Favorites Toggle Button */} - + <> +
+ {/* Favorites Toggle Button */} + - {/* Favorites Dropdown */} - {isOpen && ( -
-
-
-

My Favorites

- -
-
- -
- {loading ? ( -
Loading favorites...
- ) : favorites.length === 0 ? ( -
- -

No favorites yet

-

Click the ★ next to books, chapters, or verses to add them here

+ {/* Favorites Dropdown */} + {isOpen && ( +
+
+
+

My Favorites

+
+ + +
- ) : ( -
- {renderFavoriteSection( - "Books", - organizedFavorites.books, - - )} - {renderFavoriteSection( - "Chapters", - organizedFavorites.chapters, - - )} - {renderFavoriteSection( - "Verses", - organizedFavorites.verses, - - )} + {sections.length > 0 && ( +

+ {sections.length} section{sections.length !== 1 ? 's' : ''} +

+ )} +
+ +
+ {loading ? ( +
Loading favorites...
+ ) : favorites.length === 0 && sections.length === 0 ? ( +
+ +

No favorites yet

+

Click the star next to books, chapters, or verses to add them here

+
+ ) : ( +
+ {groupedFavorites().map(renderSectionGroup)} + {favorites.length === 0 && sections.length > 0 && ( +
+

No favorites yet. Add some by clicking the star icons!

+
+ )} +
+ )} +
+ + {/* Quick actions footer */} + {sections.length === 0 && favorites.length > 0 && ( +
+
)}
-
+ )} +
+ + {/* Sections Manager Modal */} + {showSectionsManager && ( + setShowSectionsManager(false)} + onSectionChange={() => { + onSectionChange(); + loadFavorites(); + }} + /> )} -
+ ); }; diff --git a/frontend/src/components/SectionPicker.tsx b/frontend/src/components/SectionPicker.tsx new file mode 100644 index 00000000..2d5f0feb --- /dev/null +++ b/frontend/src/components/SectionPicker.tsx @@ -0,0 +1,209 @@ +import React, { useState } from 'react'; +import { X, Plus, FolderOpen } from 'lucide-react'; +import { Section, SECTION_COLORS } from '../types/favorites'; + +interface SectionPickerProps { + sections: Section[]; + onSelect: (sectionId: number | null) => void; + onCancel: () => void; + onSectionCreated?: () => void; +} + +const SectionPicker: React.FC = ({ + sections, + onSelect, + onCancel, + onSectionCreated +}) => { + const [selectedSection, setSelectedSection] = useState(() => { + // Default to the user's default section if one exists + const defaultSection = sections.find(s => s.is_default); + return defaultSection ? defaultSection.id : null; + }); + const [showNewForm, setShowNewForm] = useState(false); + const [newSectionName, setNewSectionName] = useState(''); + const [newSectionColor, setNewSectionColor] = useState('#3B82F6'); + const [error, setError] = useState(''); + const [creating, setCreating] = useState(false); + + const createAndSelect = async () => { + if (!newSectionName.trim()) { + setError('Section name is required'); + return; + } + + setCreating(true); + try { + const response = await fetch('/api/sections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: newSectionName.trim(), color: newSectionColor }) + }); + + if (response.ok) { + const data = await response.json(); + onSectionCreated?.(); + // Select the newly created section + onSelect(data.section.id); + } else { + const data = await response.json(); + setError(data.error || 'Failed to create section'); + setCreating(false); + } + } catch (err) { + setError('Failed to create section'); + setCreating(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +

Add to Section

+
+ +
+ + {/* Section options */} +
+ {/* Uncategorized option */} +
+ ); +}; + +export default SectionPicker; diff --git a/frontend/src/components/SectionsManager.tsx b/frontend/src/components/SectionsManager.tsx new file mode 100644 index 00000000..50bc3569 --- /dev/null +++ b/frontend/src/components/SectionsManager.tsx @@ -0,0 +1,379 @@ +import React, { useState } from 'react'; +import { X, Plus, Pencil, Trash2, Check, ChevronUp, ChevronDown, Star } from 'lucide-react'; +import { Section, SECTION_COLORS } from '../types/favorites'; + +interface SectionsManagerProps { + sections: Section[]; + onClose: () => void; + onSectionChange: () => void; +} + +const SectionsManager: React.FC = ({ + sections, + onClose, + onSectionChange +}) => { + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editColor, setEditColor] = useState(''); + const [newSectionName, setNewSectionName] = useState(''); + const [newSectionColor, setNewSectionColor] = useState('#3B82F6'); + const [showNewForm, setShowNewForm] = useState(false); + const [showColorPicker, setShowColorPicker] = useState(null); + const [error, setError] = useState(''); + + const createSection = async () => { + if (!newSectionName.trim()) { + setError('Section name is required'); + return; + } + + try { + const response = await fetch('/api/sections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: newSectionName.trim(), color: newSectionColor }) + }); + + if (response.ok) { + setNewSectionName(''); + setNewSectionColor('#3B82F6'); + setShowNewForm(false); + setError(''); + onSectionChange(); + } else { + const data = await response.json(); + setError(data.error || 'Failed to create section'); + } + } catch (err) { + setError('Failed to create section'); + } + }; + + const updateSection = async (id: number) => { + if (!editName.trim()) { + setError('Section name is required'); + return; + } + + try { + const response = await fetch(`/api/sections/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: editName.trim(), color: editColor }) + }); + + if (response.ok) { + setEditingId(null); + setError(''); + onSectionChange(); + } else { + const data = await response.json(); + setError(data.error || 'Failed to update section'); + } + } catch (err) { + setError('Failed to update section'); + } + }; + + const deleteSection = async (id: number) => { + if (!confirm('Delete this section? Favorites in this section will be moved to Uncategorized.')) { + return; + } + + try { + const response = await fetch(`/api/sections/${id}`, { + method: 'DELETE', + credentials: 'include' + }); + + if (response.ok) { + onSectionChange(); + } + } catch (err) { + setError('Failed to delete section'); + } + }; + + const setDefaultSection = async (id: number) => { + try { + const response = await fetch(`/api/sections/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ is_default: true }) + }); + + if (response.ok) { + onSectionChange(); + } + } catch (err) { + setError('Failed to set default section'); + } + }; + + const moveSection = async (id: number, direction: 'up' | 'down') => { + const currentIndex = sections.findIndex(s => s.id === id); + if ( + (direction === 'up' && currentIndex === 0) || + (direction === 'down' && currentIndex === sections.length - 1) + ) { + return; + } + + const newSections = [...sections]; + const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + [newSections[currentIndex], newSections[swapIndex]] = [newSections[swapIndex], newSections[currentIndex]]; + + const sectionIds = newSections.map(s => s.id); + + try { + const response = await fetch('/api/sections/reorder', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ sectionIds }) + }); + + if (response.ok) { + onSectionChange(); + } + } catch (err) { + setError('Failed to reorder sections'); + } + }; + + const startEditing = (section: Section) => { + setEditingId(section.id); + setEditName(section.name); + setEditColor(section.color); + setShowColorPicker(null); + }; + + const ColorPicker = ({ selectedColor, onSelect, target }: { selectedColor: string; onSelect: (color: string) => void; target: number | 'new' }) => ( +
+
+ {SECTION_COLORS.map(color => ( +
+
+ ); + + return ( +
+
+ {/* Header */} +
+

Manage Sections

+ +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Sections list */} +
+ {sections.length === 0 ? ( +

+ No sections yet. Create one to organize your favorites! +

+ ) : ( +
+ {sections.map((section, index) => ( +
+ {editingId === section.id ? ( + // Edit mode + <> +
+
+ setEditName(e.target.value)} + className="flex-1 px-2 py-1 text-sm bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') updateSection(section.id); + if (e.key === 'Escape') setEditingId(null); + }} + /> + + + + ) : ( + // Display mode + <> +
+ + {section.name} + + {section.is_default && ( + + default + + )} +
+ + + {!section.is_default && ( + + )} + + +
+ + )} +
+ ))} +
+ )} +
+ + {/* Add new section */} +
+ {showNewForm ? ( +
+
+
+ setNewSectionName(e.target.value)} + placeholder="Section name..." + className="flex-1 px-2 py-1 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-gray-100" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') createSection(); + if (e.key === 'Escape') { + setShowNewForm(false); + setNewSectionName(''); + } + }} + /> + + +
+ ) : ( + + )} +
+
+
+ ); +}; + +export default SectionsManager; diff --git a/frontend/src/types/favorites.ts b/frontend/src/types/favorites.ts new file mode 100644 index 00000000..7d58595f --- /dev/null +++ b/frontend/src/types/favorites.ts @@ -0,0 +1,44 @@ +export interface Section { + id: number; + name: string; + color: string; + sort_order: number; + is_default: boolean; + created_at: string; +} + +export interface Favorite { + id: number; + book: string; + chapter?: string; + verse_start?: number; + verse_end?: number; + version: string; + note?: string; + section_id?: number; + section_name?: string; + section_color?: string; + created_at: string; +} + +// Color palette for sections +export const SECTION_COLORS = [ + { name: 'Gray', value: '#6B7280' }, + { name: 'Red', value: '#EF4444' }, + { name: 'Orange', value: '#F97316' }, + { name: 'Amber', value: '#F59E0B' }, + { name: 'Yellow', value: '#EAB308' }, + { name: 'Lime', value: '#84CC16' }, + { name: 'Green', value: '#22C55E' }, + { name: 'Emerald', value: '#10B981' }, + { name: 'Teal', value: '#14B8A6' }, + { name: 'Cyan', value: '#06B6D4' }, + { name: 'Sky', value: '#0EA5E9' }, + { name: 'Blue', value: '#3B82F6' }, + { name: 'Indigo', value: '#6366F1' }, + { name: 'Violet', value: '#8B5CF6' }, + { name: 'Purple', value: '#A855F7' }, + { name: 'Fuchsia', value: '#D946EF' }, + { name: 'Pink', value: '#EC4899' }, + { name: 'Rose', value: '#F43F5E' }, +];