Favorites improvements
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [favorites, setFavorites] = useState<any[]>([]); // Centralized favorites state
|
||||
const [sections, setSections] = useState<Section[]>([]); // 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string>; // Favorites passed from parent (centralized state)
|
||||
sections?: Section[];
|
||||
onSectionChange?: () => void;
|
||||
}
|
||||
|
||||
const BibleReader: React.FC<BibleReaderProps> = memo(({
|
||||
@@ -23,7 +27,9 @@ const BibleReader: React.FC<BibleReaderProps> = 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<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -33,6 +39,8 @@ const BibleReader: React.FC<BibleReaderProps> = memo(({
|
||||
const saved = localStorage.getItem('fontSize');
|
||||
return (saved as 'small' | 'medium' | 'large') || 'medium';
|
||||
});
|
||||
const [showSectionPicker, setShowSectionPicker] = useState(false);
|
||||
const [pendingVerse, setPendingVerse] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadChapter();
|
||||
@@ -59,18 +67,18 @@ const BibleReader: React.FC<BibleReaderProps> = 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<BibleReaderProps> = 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<BibleReaderProps> = 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<BibleReaderProps> = 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<BibleReaderProps> = memo(({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section Picker Modal */}
|
||||
{showSectionPicker && (
|
||||
<SectionPicker
|
||||
sections={sections}
|
||||
onSelect={handleSectionSelect}
|
||||
onCancel={() => {
|
||||
setShowSectionPicker(false);
|
||||
setPendingVerse(null);
|
||||
}}
|
||||
onSectionCreated={onSectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string>; // Favorites passed from parent (centralized state)
|
||||
favorites?: Set<string>;
|
||||
sections?: Section[];
|
||||
onSectionChange?: () => void;
|
||||
}
|
||||
|
||||
const BookSelector: React.FC<BookSelectorProps> = memo(({
|
||||
@@ -22,27 +26,31 @@ const BookSelector: React.FC<BookSelectorProps> = 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<string | null>(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<BookSelectorProps> = 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<BookSelectorProps> = 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 }) => (
|
||||
<div className="mb-8">
|
||||
@@ -102,18 +137,17 @@ const BookSelector: React.FC<BookSelectorProps> = memo(({
|
||||
{formatBookName(book)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Star button - only show for authenticated users */}
|
||||
|
||||
{user && (
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(book, e)}
|
||||
className="absolute top-2 right-2 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={favorites.has(book) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star
|
||||
<Star
|
||||
className={`h-4 w-4 ${
|
||||
favorites.has(book)
|
||||
? 'text-yellow-500 fill-yellow-500'
|
||||
favorites.has(book)
|
||||
? 'text-yellow-500 fill-yellow-500'
|
||||
: 'text-gray-400 hover:text-yellow-500'
|
||||
} transition-colors`}
|
||||
/>
|
||||
@@ -163,13 +197,26 @@ const BookSelector: React.FC<BookSelectorProps> = memo(({
|
||||
</p>
|
||||
{user && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Click the ★ to add books to your favorites
|
||||
Click the star to add books to your favorites
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookGroup title="Old Testament" books={oldTestament} />
|
||||
<BookGroup title="New Testament" books={newTestament} />
|
||||
|
||||
{/* Section Picker Modal */}
|
||||
{showSectionPicker && (
|
||||
<SectionPicker
|
||||
sections={sections}
|
||||
onSelect={handleSectionSelect}
|
||||
onCancel={() => {
|
||||
setShowSectionPicker(false);
|
||||
setPendingBook(null);
|
||||
}}
|
||||
onSectionCreated={onSectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string>; // Favorites passed from parent (centralized state)
|
||||
favorites?: Set<string>;
|
||||
sections?: Section[];
|
||||
onSectionChange?: () => void;
|
||||
}
|
||||
|
||||
const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
||||
@@ -23,35 +27,39 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
||||
onFavoriteChange,
|
||||
version = 'esv',
|
||||
onSearchClick,
|
||||
favorites = new Set() // Default to empty set if not provided
|
||||
favorites = new Set(),
|
||||
sections = [],
|
||||
onSectionChange
|
||||
}) => {
|
||||
const [chapters, setChapters] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSectionPicker, setShowSectionPicker] = useState(false);
|
||||
const [pendingChapter, setPendingChapter] = useState<string | null>(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<ChapterSelectorProps> = 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<ChapterSelectorProps> = 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<ChapterSelectorProps> = 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<ChapterSelectorProps> = memo(({
|
||||
</p>
|
||||
{user && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Click the ★ to add chapters to your favorites
|
||||
Click the star to add chapters to your favorites
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,18 +210,17 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
||||
{chapter}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Star button - only show for authenticated users */}
|
||||
|
||||
{user && (
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(chapter, e)}
|
||||
className="absolute top-1 right-1 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={favorites.has(chapter) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star
|
||||
<Star
|
||||
className={`h-3 w-3 ${
|
||||
favorites.has(chapter)
|
||||
? 'text-yellow-500 fill-yellow-500'
|
||||
favorites.has(chapter)
|
||||
? 'text-yellow-500 fill-yellow-500'
|
||||
: 'text-gray-400 hover:text-yellow-500'
|
||||
} transition-colors`}
|
||||
/>
|
||||
@@ -205,6 +236,19 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
||||
{chapters.length} chapters available
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Section Picker Modal */}
|
||||
{showSectionPicker && (
|
||||
<SectionPicker
|
||||
sections={sections}
|
||||
onSelect={handleSectionSelect}
|
||||
onCancel={() => {
|
||||
setShowSectionPicker(false);
|
||||
setPendingChapter(null);
|
||||
}}
|
||||
onSectionCreated={onSectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Star, ChevronDown, ChevronUp, X, Book, FileText, Quote } from 'lucide-react';
|
||||
import { Star, ChevronDown, ChevronUp, X, Settings, FolderOpen, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Favorite {
|
||||
id: number;
|
||||
book: string;
|
||||
chapter?: string;
|
||||
verse_start?: number;
|
||||
verse_end?: number;
|
||||
version: string;
|
||||
note?: string;
|
||||
created_at: string;
|
||||
}
|
||||
import { Section, Favorite } from '../types/favorites';
|
||||
import SectionsManager from './SectionsManager';
|
||||
|
||||
interface FavoritesMenuProps {
|
||||
user: any;
|
||||
@@ -19,12 +10,25 @@ interface FavoritesMenuProps {
|
||||
getBookUrlName: (bookName: string) => string;
|
||||
onFavoriteChange?: () => void;
|
||||
setSelectedVersion?: (version: string) => void;
|
||||
sections: Section[];
|
||||
onSectionChange: () => void;
|
||||
}
|
||||
|
||||
const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, getBookUrlName, onFavoriteChange, setSelectedVersion }) => {
|
||||
const FavoritesMenu: React.FC<FavoritesMenuProps> = ({
|
||||
user,
|
||||
formatBookName,
|
||||
getBookUrlName,
|
||||
onFavoriteChange,
|
||||
setSelectedVersion,
|
||||
sections,
|
||||
onSectionChange
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSectionsManager, setShowSectionsManager] = useState(false);
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
|
||||
const [movingFavorite, setMovingFavorite] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Load favorites when user is available
|
||||
@@ -36,13 +40,13 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
||||
|
||||
const loadFavorites = async () => {
|
||||
if (!user) return;
|
||||
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/favorites', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFavorites(data.favorites || []);
|
||||
@@ -60,29 +64,47 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
setFavorites(favorites.filter(f => f.id !== favoriteId));
|
||||
onFavoriteChange?.(); // Notify parent to refresh other components
|
||||
onFavoriteChange?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const moveFavoriteToSection = async (favoriteId: number, sectionId: number | null) => {
|
||||
try {
|
||||
const response = await fetch(`/api/favorites/${favoriteId}/section`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ section_id: sectionId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadFavorites();
|
||||
onFavoriteChange?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to move favorite:', error);
|
||||
} finally {
|
||||
setMovingFavorite(null);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToFavorite = (favorite: Favorite) => {
|
||||
const urlBookName = getBookUrlName(favorite.book);
|
||||
const versionPath = favorite.version || 'esv'; // Default to ESV if no version
|
||||
const versionPath = favorite.version || 'esv';
|
||||
|
||||
if (favorite.chapter) {
|
||||
// Navigate to chapter, with verse hash if it's a verse favorite
|
||||
let navUrl = `/version/${versionPath}/book/${urlBookName}/chapter/${favorite.chapter}`;
|
||||
if (favorite.verse_start) {
|
||||
navUrl += `#verse-${favorite.verse_start}`;
|
||||
}
|
||||
navigate(navUrl);
|
||||
} else {
|
||||
// Navigate to book
|
||||
navigate(`/version/${versionPath}/book/${urlBookName}`);
|
||||
}
|
||||
setIsOpen(false);
|
||||
@@ -90,7 +112,7 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
||||
|
||||
const getFavoriteDisplayText = (favorite: Favorite) => {
|
||||
const bookName = formatBookName(favorite.book);
|
||||
const versionAbbrev = favorite.version.toUpperCase(); // ESV, NKJV, etc.
|
||||
const versionAbbrev = favorite.version.toUpperCase();
|
||||
|
||||
let reference = '';
|
||||
if (favorite.verse_start && favorite.verse_end) {
|
||||
@@ -98,7 +120,7 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
||||
} else if (favorite.verse_start) {
|
||||
reference = `${bookName} ${favorite.chapter}:${favorite.verse_start}`;
|
||||
} else if (favorite.chapter) {
|
||||
reference = `${bookName} Chapter ${favorite.chapter}`;
|
||||
reference = `${bookName} ${favorite.chapter}`;
|
||||
} else {
|
||||
reference = bookName;
|
||||
}
|
||||
@@ -106,123 +128,262 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
||||
return `${reference} (${versionAbbrev})`;
|
||||
};
|
||||
|
||||
// Organize favorites by type
|
||||
const organizedFavorites = {
|
||||
books: favorites.filter(f => !f.chapter),
|
||||
chapters: favorites.filter(f => f.chapter && !f.verse_start),
|
||||
verses: favorites.filter(f => f.verse_start)
|
||||
const toggleSectionCollapse = (sectionKey: string) => {
|
||||
setCollapsedSections(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sectionKey)) {
|
||||
next.delete(sectionKey);
|
||||
} else {
|
||||
next.add(sectionKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderFavoriteSection = (title: string, items: Favorite[], icon: React.ReactNode) => {
|
||||
if (items.length === 0) return null;
|
||||
// Group favorites by section
|
||||
const groupedFavorites = () => {
|
||||
const groups: { [key: string]: { section: Section | null; favorites: Favorite[] } } = {};
|
||||
|
||||
// Initialize groups for each section
|
||||
sections.forEach(section => {
|
||||
groups[`section-${section.id}`] = { section, favorites: [] };
|
||||
});
|
||||
|
||||
// Add uncategorized group
|
||||
groups['uncategorized'] = { section: null, favorites: [] };
|
||||
|
||||
// Sort favorites into groups
|
||||
favorites.forEach(fav => {
|
||||
if (fav.section_id) {
|
||||
const key = `section-${fav.section_id}`;
|
||||
if (groups[key]) {
|
||||
groups[key].favorites.push(fav);
|
||||
} else {
|
||||
// Section was deleted, put in uncategorized
|
||||
groups['uncategorized'].favorites.push(fav);
|
||||
}
|
||||
} else {
|
||||
groups['uncategorized'].favorites.push(fav);
|
||||
}
|
||||
});
|
||||
|
||||
// Return as array, sorted by section sort_order
|
||||
return Object.entries(groups)
|
||||
.map(([key, value]) => ({ key, ...value }))
|
||||
.sort((a, b) => {
|
||||
if (a.section === null) return 1; // Uncategorized at end
|
||||
if (b.section === null) return -1;
|
||||
return a.section.sort_order - b.section.sort_order;
|
||||
})
|
||||
.filter(group => group.favorites.length > 0 || group.section !== null);
|
||||
};
|
||||
|
||||
const renderFavorite = (favorite: Favorite) => (
|
||||
<div
|
||||
key={favorite.id}
|
||||
className="flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 group"
|
||||
>
|
||||
<button
|
||||
onClick={() => navigateToFavorite(favorite)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
||||
{getFavoriteDisplayText(favorite)}
|
||||
</div>
|
||||
{favorite.note && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
|
||||
{favorite.note}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{sections.length > 0 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMovingFavorite(movingFavorite === favorite.id ? null : favorite.id)}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
|
||||
title="Move to section"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-gray-400 hover:text-blue-500" />
|
||||
</button>
|
||||
{movingFavorite === favorite.id && (
|
||||
<div className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg min-w-[150px]">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => moveFavoriteToSection(favorite.id, null)}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 ${
|
||||
!favorite.section_id ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||
Uncategorized
|
||||
</button>
|
||||
{sections.map(section => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => moveFavoriteToSection(favorite.id, section.id)}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 ${
|
||||
favorite.section_id === section.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: section.color }}
|
||||
/>
|
||||
{section.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFavorite(favorite.id)}
|
||||
className="p-1 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors"
|
||||
title="Remove favorite"
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center space-x-2 px-3 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
{icon}
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide">
|
||||
{title} ({items.length})
|
||||
</span>
|
||||
</div>
|
||||
{items.map((favorite) => (
|
||||
<div key={group.key} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleSectionCollapse(group.key)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 text-gray-400 transition-transform ${
|
||||
!isCollapsed ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
key={favorite.id}
|
||||
className="flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 group"
|
||||
>
|
||||
<button
|
||||
onClick={() => navigateToFavorite(favorite)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
||||
{getFavoriteDisplayText(favorite)}
|
||||
</div>
|
||||
{favorite.note && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
|
||||
{favorite.note}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFavorite(favorite.id)}
|
||||
className="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors"
|
||||
title="Remove favorite"
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: sectionColor }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 text-left">
|
||||
{sectionName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{group.favorites.length}
|
||||
</span>
|
||||
</button>
|
||||
{!isCollapsed && group.favorites.length > 0 && (
|
||||
<div className="border-l-2 ml-4" style={{ borderColor: sectionColor }}>
|
||||
{group.favorites.map(renderFavorite)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null; // Don't show favorites menu for non-authenticated users
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Favorites Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded hover:bg-yellow-200 dark:hover:bg-yellow-800 transition-colors"
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
<span>Favorites</span>
|
||||
{favorites.length > 0 && (
|
||||
<span className="bg-yellow-600 text-white text-xs rounded-full px-1.5 py-0.5 min-w-[1.25rem] text-center">
|
||||
{favorites.length}
|
||||
</span>
|
||||
)}
|
||||
{isOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
<>
|
||||
<div className="relative">
|
||||
{/* Favorites Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded hover:bg-yellow-200 dark:hover:bg-yellow-800 transition-colors"
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
<span>Favorites</span>
|
||||
{favorites.length > 0 && (
|
||||
<span className="bg-yellow-600 text-white text-xs rounded-full px-1.5 py-0.5 min-w-[1.25rem] text-center">
|
||||
{favorites.length}
|
||||
</span>
|
||||
)}
|
||||
{isOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Favorites Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-1rem)] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 max-h-96 overflow-hidden">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">My Favorites</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-gray-500">Loading favorites...</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<Star className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>No favorites yet</p>
|
||||
<p className="text-sm">Click the ★ next to books, chapters, or verses to add them here</p>
|
||||
{/* Favorites Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-1rem)] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 max-h-[70vh] overflow-hidden">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">My Favorites</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowSectionsManager(true)}
|
||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
title="Manage sections"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{renderFavoriteSection(
|
||||
"Books",
|
||||
organizedFavorites.books,
|
||||
<Book className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
{renderFavoriteSection(
|
||||
"Chapters",
|
||||
organizedFavorites.chapters,
|
||||
<FileText className="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
{renderFavoriteSection(
|
||||
"Verses",
|
||||
organizedFavorites.verses,
|
||||
<Quote className="h-3 w-3 text-purple-600 dark:text-purple-400" />
|
||||
)}
|
||||
{sections.length > 0 && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{sections.length} section{sections.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-gray-500">Loading favorites...</div>
|
||||
) : favorites.length === 0 && sections.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<Star className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>No favorites yet</p>
|
||||
<p className="text-sm mt-1">Click the star next to books, chapters, or verses to add them here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{groupedFavorites().map(renderSectionGroup)}
|
||||
{favorites.length === 0 && sections.length > 0 && (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
<p className="text-sm">No favorites yet. Add some by clicking the star icons!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions footer */}
|
||||
{sections.length === 0 && favorites.length > 0 && (
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowSectionsManager(true)}
|
||||
className="w-full text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Create sections to organize your favorites
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections Manager Modal */}
|
||||
{showSectionsManager && (
|
||||
<SectionsManager
|
||||
sections={sections}
|
||||
onClose={() => setShowSectionsManager(false)}
|
||||
onSectionChange={() => {
|
||||
onSectionChange();
|
||||
loadFavorites();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
209
frontend/src/components/SectionPicker.tsx
Normal file
209
frontend/src/components/SectionPicker.tsx
Normal file
@@ -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<SectionPickerProps> = ({
|
||||
sections,
|
||||
onSelect,
|
||||
onCancel,
|
||||
onSectionCreated
|
||||
}) => {
|
||||
const [selectedSection, setSelectedSection] = useState<number | null>(() => {
|
||||
// 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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-sm w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5 text-blue-500" />
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">Add to Section</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section options */}
|
||||
<div className="p-4 space-y-2 max-h-64 overflow-y-auto">
|
||||
{/* Uncategorized option */}
|
||||
<label className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="section"
|
||||
checked={selectedSection === null}
|
||||
onChange={() => setSelectedSection(null)}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Uncategorized</span>
|
||||
</label>
|
||||
|
||||
{/* User sections */}
|
||||
{sections.map(section => (
|
||||
<label
|
||||
key={section.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="section"
|
||||
checked={selectedSection === section.id}
|
||||
onChange={() => setSelectedSection(section.id)}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: section.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1">
|
||||
{section.name}
|
||||
</span>
|
||||
{section.is_default && (
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300 px-1.5 py-0.5 rounded">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create new section */}
|
||||
<div className="px-4 pb-4">
|
||||
{showNewForm ? (
|
||||
<div className="space-y-2">
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={newSectionColor}
|
||||
onChange={(e) => setNewSectionColor(e.target.value)}
|
||||
className="w-10 h-8 p-0 border border-gray-300 dark:border-gray-600 rounded cursor-pointer"
|
||||
style={{ backgroundColor: newSectionColor }}
|
||||
>
|
||||
{SECTION_COLORS.map(color => (
|
||||
<option key={color.value} value={color.value} style={{ backgroundColor: color.value }}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newSectionName}
|
||||
onChange={(e) => setNewSectionName(e.target.value)}
|
||||
placeholder="New 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' && !creating) createAndSelect();
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewForm(false);
|
||||
setNewSectionName('');
|
||||
setError('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewForm(false);
|
||||
setNewSectionName('');
|
||||
setError('');
|
||||
}}
|
||||
className="px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createAndSelect}
|
||||
disabled={creating}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create & Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowNewForm(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create new section</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelect(selectedSection)}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Add Favorite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionPicker;
|
||||
379
frontend/src/components/SectionsManager.tsx
Normal file
379
frontend/src/components/SectionsManager.tsx
Normal file
@@ -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<SectionsManagerProps> = ({
|
||||
sections,
|
||||
onClose,
|
||||
onSectionChange
|
||||
}) => {
|
||||
const [editingId, setEditingId] = useState<number | null>(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<number | 'new' | null>(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' }) => (
|
||||
<div className="absolute z-10 mt-1 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg">
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{SECTION_COLORS.map(color => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => {
|
||||
onSelect(color.value);
|
||||
setShowColorPicker(null);
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full border-2 ${
|
||||
selectedColor === color.value ? 'border-gray-900 dark:border-white' : 'border-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[80vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Manage Sections</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-4 p-2 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200 text-sm rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections list */}
|
||||
<div className="p-4 overflow-y-auto max-h-[50vh]">
|
||||
{sections.length === 0 ? (
|
||||
<p className="text-center text-gray-500 dark:text-gray-400 py-4">
|
||||
No sections yet. Create one to organize your favorites!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||
>
|
||||
{editingId === section.id ? (
|
||||
// Edit mode
|
||||
<>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColorPicker(showColorPicker === section.id ? null : section.id)}
|
||||
className="w-6 h-6 rounded-full border-2 border-gray-300"
|
||||
style={{ backgroundColor: editColor }}
|
||||
/>
|
||||
{showColorPicker === section.id && (
|
||||
<ColorPicker
|
||||
selectedColor={editColor}
|
||||
onSelect={setEditColor}
|
||||
target={section.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => 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);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateSection(section.id)}
|
||||
className="p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900 rounded"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="p-1 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Display mode
|
||||
<>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: section.color }}
|
||||
/>
|
||||
<span className="flex-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{section.name}
|
||||
</span>
|
||||
{section.is_default && (
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300 px-2 py-0.5 rounded">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => moveSection(section.id, 'up')}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveSection(section.id, 'down')}
|
||||
disabled={index === sections.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
{!section.is_default && (
|
||||
<button
|
||||
onClick={() => setDefaultSection(section.id)}
|
||||
className="p-1 text-gray-400 hover:text-yellow-500"
|
||||
title="Set as default"
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => startEditing(section)}
|
||||
className="p-1 text-gray-400 hover:text-blue-500"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteSection(section.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new section */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{showNewForm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColorPicker(showColorPicker === 'new' ? null : 'new')}
|
||||
className="w-6 h-6 rounded-full border-2 border-gray-300"
|
||||
style={{ backgroundColor: newSectionColor }}
|
||||
/>
|
||||
{showColorPicker === 'new' && (
|
||||
<ColorPicker
|
||||
selectedColor={newSectionColor}
|
||||
onSelect={setNewSectionColor}
|
||||
target="new"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newSectionName}
|
||||
onChange={(e) => 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('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={createSection}
|
||||
className="p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900 rounded"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewForm(false);
|
||||
setNewSectionName('');
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowNewForm(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Section</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionsManager;
|
||||
44
frontend/src/types/favorites.ts
Normal file
44
frontend/src/types/favorites.ts
Normal file
@@ -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' },
|
||||
];
|
||||
Reference in New Issue
Block a user