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)
|
// Favorites table (with IF NOT EXISTS for safety)
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS favorites (
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
@@ -76,12 +91,28 @@ function initializeTables() {
|
|||||||
verse_end INTEGER,
|
verse_end INTEGER,
|
||||||
version TEXT DEFAULT 'esv',
|
version TEXT DEFAULT 'esv',
|
||||||
note TEXT,
|
note TEXT,
|
||||||
|
section_id INTEGER,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
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)
|
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');
|
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
|
// Favorites operations
|
||||||
const favoritesOps = {
|
const favoritesOps = {
|
||||||
// Get user favorites
|
// Get user favorites with section info
|
||||||
getFavorites: (userId, callback) => {
|
getFavorites: (userId, callback) => {
|
||||||
db.all(
|
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],
|
[userId],
|
||||||
callback
|
callback
|
||||||
);
|
);
|
||||||
@@ -184,12 +329,12 @@ const favoritesOps = {
|
|||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
addFavorite: (userId, favorite, callback) => {
|
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(
|
db.run(
|
||||||
`INSERT INTO favorites (user_id, book, chapter, verse_start, verse_end, version, note)
|
`INSERT INTO favorites (user_id, book, chapter, verse_start, verse_end, version, note, section_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[userId, book, chapter, verse_start, verse_end, version || 'esv', note],
|
[userId, book, chapter, verse_start, verse_end, version || 'esv', note, section_id || null],
|
||||||
callback
|
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
|
// Check if verse is favorited
|
||||||
isFavorited: (userId, book, chapter, verse_start, verse_end, version, callback) => {
|
isFavorited: (userId, book, chapter, verse_start, verse_end, version, callback) => {
|
||||||
db.get(
|
db.get(
|
||||||
@@ -220,5 +374,6 @@ module.exports = {
|
|||||||
db,
|
db,
|
||||||
userOps,
|
userOps,
|
||||||
preferencesOps,
|
preferencesOps,
|
||||||
favoritesOps
|
favoritesOps,
|
||||||
|
sectionsOps
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const helmet = require('helmet');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { configureAuth, requireAuth, optionalAuth } = require('./auth');
|
const { configureAuth, requireAuth, optionalAuth } = require('./auth');
|
||||||
const { preferencesOps, favoritesOps } = require('./database');
|
const { preferencesOps, favoritesOps, sectionsOps } = require('./database');
|
||||||
const SearchDatabase = require('./searchDatabase');
|
const SearchDatabase = require('./searchDatabase');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -384,7 +384,7 @@ app.get('/api/favorites', requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/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)
|
// Book is required, but chapter is optional (for book-level favorites)
|
||||||
if (!book) {
|
if (!book) {
|
||||||
@@ -397,7 +397,8 @@ app.post('/api/favorites', requireAuth, (req, res) => {
|
|||||||
verse_start: verse_start || null,
|
verse_start: verse_start || null,
|
||||||
verse_end: verse_end || null,
|
verse_end: verse_end || null,
|
||||||
version: version || 'esv',
|
version: version || 'esv',
|
||||||
note: note || null
|
note: note || null,
|
||||||
|
section_id: section_id || null
|
||||||
};
|
};
|
||||||
|
|
||||||
favoritesOps.addFavorite(req.user.id, favorite, function(err) {
|
favoritesOps.addFavorite(req.user.id, favorite, function(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
|
// Catch-all handler: send back React's index.html for client-side routing
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../../frontend/build/index.html'));
|
res.sendFile(path.join(__dirname, '../../frontend/build/index.html'));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import FavoritesMenu from './components/FavoritesMenu';
|
|||||||
import SearchComponent from './components/SearchComponent';
|
import SearchComponent from './components/SearchComponent';
|
||||||
import VersionSelector from './components/VersionSelector';
|
import VersionSelector from './components/VersionSelector';
|
||||||
import { getBooks } from './services/api';
|
import { getBooks } from './services/api';
|
||||||
|
import { Section } from './types/favorites';
|
||||||
|
|
||||||
interface BookData {
|
interface BookData {
|
||||||
books: string[];
|
books: string[];
|
||||||
@@ -29,6 +30,7 @@ function App() {
|
|||||||
const [versionSelected, setVersionSelected] = useState(false); // Track if version has been chosen
|
const [versionSelected, setVersionSelected] = useState(false); // Track if version has been chosen
|
||||||
const [availableVersions, setAvailableVersions] = useState<any[]>([]);
|
const [availableVersions, setAvailableVersions] = useState<any[]>([]);
|
||||||
const [favorites, setFavorites] = useState<any[]>([]); // Centralized favorites state
|
const [favorites, setFavorites] = useState<any[]>([]); // Centralized favorites state
|
||||||
|
const [sections, setSections] = useState<Section[]>([]); // Sections for organizing favorites
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -72,8 +74,10 @@ function App() {
|
|||||||
if (user) {
|
if (user) {
|
||||||
loadUserPreferences();
|
loadUserPreferences();
|
||||||
loadFavorites(); // Load favorites once when user logs in
|
loadFavorites(); // Load favorites once when user logs in
|
||||||
|
loadSections(); // Load sections when user logs in
|
||||||
} else {
|
} else {
|
||||||
setFavorites([]); // Clear favorites when user logs out
|
setFavorites([]); // Clear favorites when user logs out
|
||||||
|
setSections([]); // Clear sections when user logs out
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [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
|
// Helper functions to filter favorites by type
|
||||||
const getBookFavorites = (version: string) => {
|
const getBookFavorites = (version: string) => {
|
||||||
return new Set(
|
return new Set(
|
||||||
@@ -361,6 +393,8 @@ function App() {
|
|||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
onSearchClick={() => setShowSearch(true)}
|
onSearchClick={() => setShowSearch(true)}
|
||||||
favorites={versionId ? getBookFavorites(versionId) : new Set()}
|
favorites={versionId ? getBookFavorites(versionId) : new Set()}
|
||||||
|
sections={sections}
|
||||||
|
onSectionChange={handleSectionChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -393,6 +427,8 @@ function App() {
|
|||||||
version={versionId}
|
version={versionId}
|
||||||
onSearchClick={() => setShowSearch(true)}
|
onSearchClick={() => setShowSearch(true)}
|
||||||
favorites={versionId ? getChapterFavorites(actualBookName, versionId) : new Set()}
|
favorites={versionId ? getChapterFavorites(actualBookName, versionId) : new Set()}
|
||||||
|
sections={sections}
|
||||||
|
onSectionChange={handleSectionChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -421,6 +457,8 @@ function App() {
|
|||||||
version={selectedVersion}
|
version={selectedVersion}
|
||||||
onSearchClick={() => setShowSearch(true)}
|
onSearchClick={() => setShowSearch(true)}
|
||||||
favorites={getVerseFavorites(actualBookName, chapterNumber, selectedVersion)}
|
favorites={getVerseFavorites(actualBookName, chapterNumber, selectedVersion)}
|
||||||
|
sections={sections}
|
||||||
|
onSectionChange={handleSectionChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -547,6 +585,8 @@ function App() {
|
|||||||
getBookUrlName={getBookUrlName}
|
getBookUrlName={getBookUrlName}
|
||||||
setSelectedVersion={setSelectedVersion}
|
setSelectedVersion={setSelectedVersion}
|
||||||
onFavoriteChange={handleFavoriteChange}
|
onFavoriteChange={handleFavoriteChange}
|
||||||
|
sections={sections}
|
||||||
|
onSectionChange={handleSectionChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect, memo } from 'react';
|
import React, { useState, useEffect, memo } from 'react';
|
||||||
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, Star, Search } from 'lucide-react';
|
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, Star, Search } from 'lucide-react';
|
||||||
import { getChapter, getBook } from '../services/api';
|
import { getChapter, getBook } from '../services/api';
|
||||||
|
import { Section } from '../types/favorites';
|
||||||
|
import SectionPicker from './SectionPicker';
|
||||||
|
|
||||||
interface BibleReaderProps {
|
interface BibleReaderProps {
|
||||||
book: string;
|
book: string;
|
||||||
@@ -12,6 +14,8 @@ interface BibleReaderProps {
|
|||||||
version?: string;
|
version?: string;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
favorites?: Set<string>; // Favorites passed from parent (centralized state)
|
favorites?: Set<string>; // Favorites passed from parent (centralized state)
|
||||||
|
sections?: Section[];
|
||||||
|
onSectionChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BibleReader: React.FC<BibleReaderProps> = memo(({
|
const BibleReader: React.FC<BibleReaderProps> = memo(({
|
||||||
@@ -23,7 +27,9 @@ const BibleReader: React.FC<BibleReaderProps> = memo(({
|
|||||||
onFavoriteChange,
|
onFavoriteChange,
|
||||||
version = 'esv',
|
version = 'esv',
|
||||||
onSearchClick,
|
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 [content, setContent] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -33,6 +39,8 @@ const BibleReader: React.FC<BibleReaderProps> = memo(({
|
|||||||
const saved = localStorage.getItem('fontSize');
|
const saved = localStorage.getItem('fontSize');
|
||||||
return (saved as 'small' | 'medium' | 'large') || 'medium';
|
return (saved as 'small' | 'medium' | 'large') || 'medium';
|
||||||
});
|
});
|
||||||
|
const [showSectionPicker, setShowSectionPicker] = useState(false);
|
||||||
|
const [pendingVerse, setPendingVerse] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChapter();
|
loadChapter();
|
||||||
@@ -59,7 +67,7 @@ const BibleReader: React.FC<BibleReaderProps> = memo(({
|
|||||||
}
|
}
|
||||||
}, [loading, content]);
|
}, [loading, content]);
|
||||||
|
|
||||||
const toggleFavorite = async (verseNumber: string) => {
|
const toggleFavorite = async (verseNumber: string, sectionId?: number | null) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
const isFavorited = favorites.has(verseNumber);
|
const isFavorited = favorites.has(verseNumber);
|
||||||
@@ -88,12 +96,35 @@ const BibleReader: React.FC<BibleReaderProps> = memo(({
|
|||||||
|
|
||||||
if (deleteResponse.ok) {
|
if (deleteResponse.ok) {
|
||||||
console.log('Removed verse favorite:', verseNumber);
|
console.log('Removed verse favorite:', verseNumber);
|
||||||
onFavoriteChange?.(); // Notify parent to refresh favorites
|
onFavoriteChange?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add favorite
|
// 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) {
|
||||||
|
console.error('Failed to toggle favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFavoriteWithSection = async (verseNumber: string, sectionId: number | null) => {
|
||||||
|
try {
|
||||||
const response = await fetch('/api/favorites', {
|
const response = await fetch('/api/favorites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -104,24 +135,31 @@ const BibleReader: React.FC<BibleReaderProps> = memo(({
|
|||||||
book: book,
|
book: book,
|
||||||
chapter: chapter,
|
chapter: chapter,
|
||||||
verse_start: parseInt(verseNumber),
|
verse_start: parseInt(verseNumber),
|
||||||
version: version
|
version: version,
|
||||||
|
section_id: sectionId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('Added verse favorite:', verseNumber);
|
console.log('Added verse favorite:', verseNumber, 'to section:', sectionId);
|
||||||
onFavoriteChange?.(); // Notify parent to refresh favorites
|
onFavoriteChange?.();
|
||||||
} else if (response.status === 409) {
|
} else if (response.status === 409) {
|
||||||
// 409 means it already exists, which is fine
|
|
||||||
console.log('Verse favorite already exists:', verseNumber);
|
console.log('Verse favorite already exists:', verseNumber);
|
||||||
onFavoriteChange?.(); // Notify parent to refresh favorites
|
onFavoriteChange?.();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle favorite:', 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 () => {
|
const loadChapters = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/books/${book}?version=${version}`);
|
const response = await fetch(`/books/${book}?version=${version}`);
|
||||||
@@ -400,6 +438,19 @@ const BibleReader: React.FC<BibleReaderProps> = memo(({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section Picker Modal */}
|
||||||
|
{showSectionPicker && (
|
||||||
|
<SectionPicker
|
||||||
|
sections={sections}
|
||||||
|
onSelect={handleSectionSelect}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowSectionPicker(false);
|
||||||
|
setPendingVerse(null);
|
||||||
|
}}
|
||||||
|
onSectionCreated={onSectionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect, memo } from 'react';
|
import React, { useState, memo } from 'react';
|
||||||
import { BookOpen, Star, ChevronLeft, Search } from 'lucide-react';
|
import { BookOpen, Star, Search } from 'lucide-react';
|
||||||
|
import { Section } from '../types/favorites';
|
||||||
|
import SectionPicker from './SectionPicker';
|
||||||
|
|
||||||
interface BookSelectorProps {
|
interface BookSelectorProps {
|
||||||
books: string[];
|
books: string[];
|
||||||
@@ -10,7 +12,9 @@ interface BookSelectorProps {
|
|||||||
version?: string;
|
version?: string;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onSearchClick?: () => 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(({
|
const BookSelector: React.FC<BookSelectorProps> = memo(({
|
||||||
@@ -22,11 +26,15 @@ const BookSelector: React.FC<BookSelectorProps> = memo(({
|
|||||||
version = 'esv',
|
version = 'esv',
|
||||||
onBack,
|
onBack,
|
||||||
onSearchClick,
|
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) => {
|
const toggleFavorite = async (book: string, event: React.MouseEvent, sectionId?: number | null) => {
|
||||||
event.stopPropagation(); // Prevent book selection when clicking star
|
event.stopPropagation();
|
||||||
|
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
@@ -51,12 +59,31 @@ const BookSelector: React.FC<BookSelectorProps> = memo(({
|
|||||||
|
|
||||||
if (deleteResponse.ok) {
|
if (deleteResponse.ok) {
|
||||||
console.log('Removed book favorite:', book);
|
console.log('Removed book favorite:', book);
|
||||||
onFavoriteChange?.(); // Notify parent to refresh favorites
|
onFavoriteChange?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add favorite - simplified like ChapterSelector
|
// 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 {
|
||||||
|
await addFavoriteWithSection(book, sectionId ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFavoriteWithSection = async (book: string, sectionId: number | null) => {
|
||||||
|
try {
|
||||||
const response = await fetch('/api/favorites', {
|
const response = await fetch('/api/favorites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -65,25 +92,33 @@ const BookSelector: React.FC<BookSelectorProps> = memo(({
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
book: book,
|
book: book,
|
||||||
version: version
|
version: version,
|
||||||
|
section_id: sectionId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('Added book favorite:', book);
|
console.log('Added book favorite:', book, 'to section:', sectionId);
|
||||||
onFavoriteChange?.(); // Notify parent to refresh favorites
|
onFavoriteChange?.();
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to add favorite:', response.status, response.statusText);
|
console.error('Failed to add favorite:', response.status, response.statusText);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle favorite:', 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
|
// Group books by testament
|
||||||
const oldTestament = books.slice(0, 39); // First 39 books
|
const oldTestament = books.slice(0, 39);
|
||||||
const newTestament = books.slice(39); // Remaining books
|
const newTestament = books.slice(39);
|
||||||
|
|
||||||
const BookGroup: React.FC<{ title: string; books: string[] }> = ({ title, books }) => (
|
const BookGroup: React.FC<{ title: string; books: string[] }> = ({ title, books }) => (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -103,7 +138,6 @@ const BookSelector: React.FC<BookSelectorProps> = memo(({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Star button - only show for authenticated users */}
|
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => toggleFavorite(book, e)}
|
onClick={(e) => toggleFavorite(book, e)}
|
||||||
@@ -163,13 +197,26 @@ const BookSelector: React.FC<BookSelectorProps> = memo(({
|
|||||||
</p>
|
</p>
|
||||||
{user && (
|
{user && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BookGroup title="Old Testament" books={oldTestament} />
|
<BookGroup title="Old Testament" books={oldTestament} />
|
||||||
<BookGroup title="New Testament" books={newTestament} />
|
<BookGroup title="New Testament" books={newTestament} />
|
||||||
|
|
||||||
|
{/* Section Picker Modal */}
|
||||||
|
{showSectionPicker && (
|
||||||
|
<SectionPicker
|
||||||
|
sections={sections}
|
||||||
|
onSelect={handleSectionSelect}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowSectionPicker(false);
|
||||||
|
setPendingBook(null);
|
||||||
|
}}
|
||||||
|
onSectionCreated={onSectionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect, memo } from 'react';
|
import React, { useState, useEffect, memo } from 'react';
|
||||||
import { ArrowLeft, FileText, Star, ChevronRight, Search } from 'lucide-react';
|
import { ArrowLeft, FileText, Star, ChevronRight, Search } from 'lucide-react';
|
||||||
import { getBook } from '../services/api';
|
import { getBook } from '../services/api';
|
||||||
|
import { Section } from '../types/favorites';
|
||||||
|
import SectionPicker from './SectionPicker';
|
||||||
|
|
||||||
interface ChapterSelectorProps {
|
interface ChapterSelectorProps {
|
||||||
book: string;
|
book: string;
|
||||||
@@ -11,7 +13,9 @@ interface ChapterSelectorProps {
|
|||||||
onFavoriteChange?: () => void;
|
onFavoriteChange?: () => void;
|
||||||
version?: string;
|
version?: string;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
favorites?: Set<string>; // Favorites passed from parent (centralized state)
|
favorites?: Set<string>;
|
||||||
|
sections?: Section[];
|
||||||
|
onSectionChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
||||||
@@ -23,17 +27,21 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
|||||||
onFavoriteChange,
|
onFavoriteChange,
|
||||||
version = 'esv',
|
version = 'esv',
|
||||||
onSearchClick,
|
onSearchClick,
|
||||||
favorites = new Set() // Default to empty set if not provided
|
favorites = new Set(),
|
||||||
|
sections = [],
|
||||||
|
onSectionChange
|
||||||
}) => {
|
}) => {
|
||||||
const [chapters, setChapters] = useState<string[]>([]);
|
const [chapters, setChapters] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showSectionPicker, setShowSectionPicker] = useState(false);
|
||||||
|
const [pendingChapter, setPendingChapter] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChapters();
|
loadChapters();
|
||||||
}, [book]);
|
}, [book]);
|
||||||
|
|
||||||
const toggleFavorite = async (chapter: string, event: React.MouseEvent) => {
|
const toggleFavorite = async (chapter: string, event: React.MouseEvent, sectionId?: number | null) => {
|
||||||
event.stopPropagation(); // Prevent chapter selection when clicking star
|
event.stopPropagation();
|
||||||
|
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
@@ -59,11 +67,30 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('Removed chapter favorite:', chapter);
|
console.log('Removed chapter favorite:', chapter);
|
||||||
onFavoriteChange?.(); // Notify parent to refresh favorites
|
onFavoriteChange?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add favorite
|
// 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) {
|
||||||
|
console.error('Failed to toggle favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFavoriteWithSection = async (chapter: string, sectionId: number | null) => {
|
||||||
|
try {
|
||||||
const response = await fetch('/api/favorites', {
|
const response = await fetch('/api/favorites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -73,28 +100,34 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
book: book,
|
book: book,
|
||||||
chapter: chapter,
|
chapter: chapter,
|
||||||
version: version
|
version: version,
|
||||||
|
section_id: sectionId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('Added chapter favorite:', chapter);
|
console.log('Added chapter favorite:', chapter, 'to section:', sectionId);
|
||||||
onFavoriteChange?.(); // Notify parent to refresh favorites
|
onFavoriteChange?.();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle favorite:', 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 () => {
|
const loadChapters = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await getBook(book, version);
|
const response = await getBook(book, version);
|
||||||
|
|
||||||
// The API now returns { chapters: ["1", "2", "3", ...] }
|
|
||||||
if (response.chapters) {
|
if (response.chapters) {
|
||||||
// Sort chapters numerically to ensure proper order
|
|
||||||
const sortedChapters = response.chapters.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
const sortedChapters = response.chapters.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||||
setChapters(sortedChapters);
|
setChapters(sortedChapters);
|
||||||
} else {
|
} else {
|
||||||
@@ -103,7 +136,6 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load chapters:', error);
|
console.error('Failed to load chapters:', error);
|
||||||
// Don't show fallback chapters - just show an empty list
|
|
||||||
setChapters([]);
|
setChapters([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -160,7 +192,7 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
|||||||
</p>
|
</p>
|
||||||
{user && (
|
{user && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +211,6 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Star button - only show for authenticated users */}
|
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => toggleFavorite(chapter, e)}
|
onClick={(e) => toggleFavorite(chapter, e)}
|
||||||
@@ -205,6 +236,19 @@ const ChapterSelector: React.FC<ChapterSelectorProps> = memo(({
|
|||||||
{chapters.length} chapters available
|
{chapters.length} chapters available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section Picker Modal */}
|
||||||
|
{showSectionPicker && (
|
||||||
|
<SectionPicker
|
||||||
|
sections={sections}
|
||||||
|
onSelect={handleSectionSelect}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowSectionPicker(false);
|
||||||
|
setPendingChapter(null);
|
||||||
|
}}
|
||||||
|
onSectionCreated={onSectionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Section, Favorite } from '../types/favorites';
|
||||||
interface Favorite {
|
import SectionsManager from './SectionsManager';
|
||||||
id: number;
|
|
||||||
book: string;
|
|
||||||
chapter?: string;
|
|
||||||
verse_start?: number;
|
|
||||||
verse_end?: number;
|
|
||||||
version: string;
|
|
||||||
note?: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FavoritesMenuProps {
|
interface FavoritesMenuProps {
|
||||||
user: any;
|
user: any;
|
||||||
@@ -19,12 +10,25 @@ interface FavoritesMenuProps {
|
|||||||
getBookUrlName: (bookName: string) => string;
|
getBookUrlName: (bookName: string) => string;
|
||||||
onFavoriteChange?: () => void;
|
onFavoriteChange?: () => void;
|
||||||
setSelectedVersion?: (version: string) => 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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Load favorites when user is available
|
// Load favorites when user is available
|
||||||
@@ -63,26 +67,44 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setFavorites(favorites.filter(f => f.id !== favoriteId));
|
setFavorites(favorites.filter(f => f.id !== favoriteId));
|
||||||
onFavoriteChange?.(); // Notify parent to refresh other components
|
onFavoriteChange?.();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove favorite:', 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 navigateToFavorite = (favorite: Favorite) => {
|
||||||
const urlBookName = getBookUrlName(favorite.book);
|
const urlBookName = getBookUrlName(favorite.book);
|
||||||
const versionPath = favorite.version || 'esv'; // Default to ESV if no version
|
const versionPath = favorite.version || 'esv';
|
||||||
|
|
||||||
if (favorite.chapter) {
|
if (favorite.chapter) {
|
||||||
// Navigate to chapter, with verse hash if it's a verse favorite
|
|
||||||
let navUrl = `/version/${versionPath}/book/${urlBookName}/chapter/${favorite.chapter}`;
|
let navUrl = `/version/${versionPath}/book/${urlBookName}/chapter/${favorite.chapter}`;
|
||||||
if (favorite.verse_start) {
|
if (favorite.verse_start) {
|
||||||
navUrl += `#verse-${favorite.verse_start}`;
|
navUrl += `#verse-${favorite.verse_start}`;
|
||||||
}
|
}
|
||||||
navigate(navUrl);
|
navigate(navUrl);
|
||||||
} else {
|
} else {
|
||||||
// Navigate to book
|
|
||||||
navigate(`/version/${versionPath}/book/${urlBookName}`);
|
navigate(`/version/${versionPath}/book/${urlBookName}`);
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -90,7 +112,7 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
|||||||
|
|
||||||
const getFavoriteDisplayText = (favorite: Favorite) => {
|
const getFavoriteDisplayText = (favorite: Favorite) => {
|
||||||
const bookName = formatBookName(favorite.book);
|
const bookName = formatBookName(favorite.book);
|
||||||
const versionAbbrev = favorite.version.toUpperCase(); // ESV, NKJV, etc.
|
const versionAbbrev = favorite.version.toUpperCase();
|
||||||
|
|
||||||
let reference = '';
|
let reference = '';
|
||||||
if (favorite.verse_start && favorite.verse_end) {
|
if (favorite.verse_start && favorite.verse_end) {
|
||||||
@@ -98,7 +120,7 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
|||||||
} else if (favorite.verse_start) {
|
} else if (favorite.verse_start) {
|
||||||
reference = `${bookName} ${favorite.chapter}:${favorite.verse_start}`;
|
reference = `${bookName} ${favorite.chapter}:${favorite.verse_start}`;
|
||||||
} else if (favorite.chapter) {
|
} else if (favorite.chapter) {
|
||||||
reference = `${bookName} Chapter ${favorite.chapter}`;
|
reference = `${bookName} ${favorite.chapter}`;
|
||||||
} else {
|
} else {
|
||||||
reference = bookName;
|
reference = bookName;
|
||||||
}
|
}
|
||||||
@@ -106,25 +128,57 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
|||||||
return `${reference} (${versionAbbrev})`;
|
return `${reference} (${versionAbbrev})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Organize favorites by type
|
const toggleSectionCollapse = (sectionKey: string) => {
|
||||||
const organizedFavorites = {
|
setCollapsedSections(prev => {
|
||||||
books: favorites.filter(f => !f.chapter),
|
const next = new Set(prev);
|
||||||
chapters: favorites.filter(f => f.chapter && !f.verse_start),
|
if (next.has(sectionKey)) {
|
||||||
verses: favorites.filter(f => f.verse_start)
|
next.delete(sectionKey);
|
||||||
|
} else {
|
||||||
|
next.add(sectionKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFavoriteSection = (title: string, items: Favorite[], icon: React.ReactNode) => {
|
// Group favorites by section
|
||||||
if (items.length === 0) return null;
|
const groupedFavorites = () => {
|
||||||
|
const groups: { [key: string]: { section: Section | null; favorites: Favorite[] } } = {};
|
||||||
|
|
||||||
return (
|
// Initialize groups for each section
|
||||||
<div className="mb-2">
|
sections.forEach(section => {
|
||||||
<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">
|
groups[`section-${section.id}`] = { section, favorites: [] };
|
||||||
{icon}
|
});
|
||||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide">
|
|
||||||
{title} ({items.length})
|
// Add uncategorized group
|
||||||
</span>
|
groups['uncategorized'] = { section: null, favorites: [] };
|
||||||
</div>
|
|
||||||
{items.map((favorite) => (
|
// 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
|
<div
|
||||||
key={favorite.id}
|
key={favorite.id}
|
||||||
className="flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 group"
|
className="flex items-center justify-between px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 group"
|
||||||
@@ -142,24 +196,101 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => removeFavorite(favorite.id)}
|
onClick={() => removeFavorite(favorite.id)}
|
||||||
className="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors"
|
className="p-1 hover:bg-red-100 dark:hover:bg-red-900 rounded transition-colors"
|
||||||
title="Remove favorite"
|
title="Remove favorite"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-red-500" />
|
<X className="h-4 w-4 text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null; // Don't show favorites menu for non-authenticated users
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Favorites Toggle Button */}
|
{/* Favorites Toggle Button */}
|
||||||
<button
|
<button
|
||||||
@@ -178,10 +309,18 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
|||||||
|
|
||||||
{/* Favorites Dropdown */}
|
{/* Favorites Dropdown */}
|
||||||
{isOpen && (
|
{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="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="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">My Favorites</h3>
|
<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
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||||
@@ -190,39 +329,61 @@ const FavoritesMenu: React.FC<FavoritesMenuProps> = ({ user, formatBookName, get
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{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-80 overflow-y-auto">
|
<div className="max-h-[50vh] overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-4 text-center text-gray-500">Loading favorites...</div>
|
<div className="p-4 text-center text-gray-500">Loading favorites...</div>
|
||||||
) : favorites.length === 0 ? (
|
) : favorites.length === 0 && sections.length === 0 ? (
|
||||||
<div className="p-4 text-center text-gray-500">
|
<div className="p-4 text-center text-gray-500">
|
||||||
<Star className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
<Star className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||||
<p>No favorites yet</p>
|
<p>No favorites yet</p>
|
||||||
<p className="text-sm">Click the ★ next to books, chapters, or verses to add them here</p>
|
<p className="text-sm mt-1">Click the star next to books, chapters, or verses to add them here</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{renderFavoriteSection(
|
{groupedFavorites().map(renderSectionGroup)}
|
||||||
"Books",
|
{favorites.length === 0 && sections.length > 0 && (
|
||||||
organizedFavorites.books,
|
<div className="p-4 text-center text-gray-500">
|
||||||
<Book className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
<p className="text-sm">No favorites yet. Add some by clicking the star icons!</p>
|
||||||
)}
|
</div>
|
||||||
{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" />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections Manager Modal */}
|
||||||
|
{showSectionsManager && (
|
||||||
|
<SectionsManager
|
||||||
|
sections={sections}
|
||||||
|
onClose={() => setShowSectionsManager(false)}
|
||||||
|
onSectionChange={() => {
|
||||||
|
onSectionChange();
|
||||||
|
loadFavorites();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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