Add universal search with dropdown to sidebar and homepage

- Add /api/universal-search endpoint that searches across books, verses,
  topics, stories, and reading plans
- Update sidebar search with live dropdown showing categorized results
- Add same dropdown functionality to homepage search
- Support smart verse reference parsing (e.g., "gen 1:1" → Genesis 1:1)
- Include keyboard navigation (arrow keys, Enter, Escape)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-26 10:59:18 -05:00
parent c03af4ed28
commit 6df0ecda1f
3 changed files with 716 additions and 117 deletions
+71
View File
@@ -104,6 +104,77 @@ def search_api(
}
@router.get("/universal-search")
def universal_search_api(
q: str = Query(..., description="Search query", examples=["love"]),
limit: int = Query(5, description="Max results per category", examples=[5])
):
"""Universal search across all content types."""
if not q or len(q.strip()) < 2:
return {"query": q, "results": {}}
query = q.strip().lower()
results = {}
# Search Bible books
all_books = bible.get_books()
matching_books = [
{"name": book, "url": f"/book/{book}"}
for book in all_books
if query in book.lower()
][:limit]
if matching_books:
results["books"] = matching_books
# Search Bible verses (limit to top results for speed)
verse_results = perform_full_text_search(q.strip(), limit)
if verse_results:
results["verses"] = [
{
"reference": r["reference"],
"text": r["text"][:100] + "..." if len(r.get("text", "")) > 100 else r.get("text", ""),
"url": f"/book/{r['book']}/chapter/{r['chapter']}/verse/{r['verse']}"
}
for r in verse_results
]
# Search topics
all_topics = get_all_topics()
matching_topics = [
{"name": name.replace("_", " ").title(), "url": f"/topics/{name}"}
for name, data in all_topics.items()
if query in name.lower() or query in data.get("description", "").lower()
][:limit]
if matching_topics:
results["topics"] = matching_topics
# Search stories
all_stories = get_all_stories_flat()
matching_stories = [
{
"title": s["title"],
"url": f"/stories/{s['slug']}",
"category": s.get("category_name", "")
}
for s in all_stories
if query in s.get("title", "").lower() or query in s.get("description", "").lower()
][:limit]
if matching_stories:
results["stories"] = matching_stories
# Search reading plans
from ..reading_plans import READING_PLANS
matching_plans = [
{"name": plan["name"], "url": f"/reading-plans/{plan_id}"}
for plan_id, plan in READING_PLANS.items()
if query in plan["name"].lower() or query in plan.get("description", "").lower()
][:limit]
if matching_plans:
results["plans"] = matching_plans
return {"query": q, "results": results}
@router.get("/verse-of-the-day")
def verse_of_the_day_api():
"""API endpoint for verse of the day."""
+306 -35
View File
@@ -463,7 +463,111 @@
border-color: var(--text-tertiary);
}
/* Universal search dropdown */
.search-results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-color);
border: 1px solid var(--border-color-dark);
border-top: none;
border-radius: 0 0 3px 3px;
max-height: 400px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: none;
}
.search-results-dropdown.show {
display: block;
}
.search-results-category {
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.search-results-category:last-child {
border-bottom: none;
}
.search-results-category-title {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
margin-bottom: 0.3rem;
font-weight: 600;
}
.search-result-item {
display: block;
padding: 0.3rem 0.4rem;
font-size: 0.75rem;
color: var(--text-color);
text-decoration: none;
border-bottom: none;
border-radius: 2px;
transition: background-color 0.15s;
}
.search-result-item:hover,
.search-result-item.selected {
background: var(--border-color);
color: var(--link-hover);
}
.search-result-item .result-title {
display: block;
font-weight: 500;
}
.search-result-item .result-meta {
font-size: 0.65rem;
color: var(--text-tertiary);
display: block;
margin-top: 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-no-results {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.75rem;
font-style: italic;
}
.search-loading {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.75rem;
}
.search-view-all {
display: block;
padding: 0.5rem;
text-align: center;
font-size: 0.7rem;
background: var(--border-color);
color: var(--text-secondary);
text-decoration: none;
border-bottom: none;
}
.search-view-all:hover {
background: var(--border-color-dark);
color: var(--link-hover);
}
[data-theme="dark"] .search-results-dropdown {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* Resource grouping */
.resource-group {
@@ -1096,9 +1200,10 @@
<!-- Floating Navigation Sidebar -->
<nav class="nav-sidebar">
<!-- Search Box -->
<!-- Universal Search Box -->
<div class="sidebar-search">
<input type="text" id="sidebar-search-input" placeholder="Search..." autocomplete="off">
<input type="text" id="sidebar-search-input" placeholder="Search everything..." autocomplete="off">
<div id="search-results-dropdown" class="search-results-dropdown"></div>
</div>
<!-- Quick Access -->
@@ -1234,47 +1339,213 @@
});
})();
// Sidebar search functionality
// Universal search functionality with smart verse navigation
(function() {
var searchInput = document.getElementById('sidebar-search-input');
if (!searchInput) return;
var dropdown = document.getElementById('search-results-dropdown');
if (!searchInput || !dropdown) return;
var oldTestament = document.getElementById('old-testament');
var newTestament = document.getElementById('new-testament');
var searchTimeout = null;
var selectedIndex = -1;
var currentResults = [];
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && this.value.trim()) {
window.location.href = '/search?q=' + encodeURIComponent(this.value.trim());
// Book name mapping (same as homepage)
var bookMap = {
'genesis': 'Genesis', 'exodus': 'Exodus', 'leviticus': 'Leviticus', 'numbers': 'Numbers',
'deuteronomy': 'Deuteronomy', 'joshua': 'Joshua', 'judges': 'Judges', 'ruth': 'Ruth',
'1 samuel': '1 Samuel', '2 samuel': '2 Samuel', '1 kings': '1 Kings', '2 kings': '2 Kings',
'1 chronicles': '1 Chronicles', '2 chronicles': '2 Chronicles', 'ezra': 'Ezra', 'nehemiah': 'Nehemiah',
'esther': 'Esther', 'job': 'Job', 'psalms': 'Psalms', 'psalm': 'Psalms', 'proverbs': 'Proverbs',
'ecclesiastes': 'Ecclesiastes', 'song of solomon': 'Song of Solomon', 'isaiah': 'Isaiah',
'jeremiah': 'Jeremiah', 'lamentations': 'Lamentations', 'ezekiel': 'Ezekiel', 'daniel': 'Daniel',
'hosea': 'Hosea', 'joel': 'Joel', 'amos': 'Amos', 'obadiah': 'Obadiah', 'jonah': 'Jonah',
'micah': 'Micah', 'nahum': 'Nahum', 'habakkuk': 'Habakkuk', 'zephaniah': 'Zephaniah',
'haggai': 'Haggai', 'zechariah': 'Zechariah', 'malachi': 'Malachi', 'matthew': 'Matthew',
'mark': 'Mark', 'luke': 'Luke', 'john': 'John', 'acts': 'Acts', 'romans': 'Romans',
'1 corinthians': '1 Corinthians', '2 corinthians': '2 Corinthians', 'galatians': 'Galatians',
'ephesians': 'Ephesians', 'philippians': 'Philippians', 'colossians': 'Colossians',
'1 thessalonians': '1 Thessalonians', '2 thessalonians': '2 Thessalonians',
'1 timothy': '1 Timothy', '2 timothy': '2 Timothy', 'titus': 'Titus', 'philemon': 'Philemon',
'hebrews': 'Hebrews', 'james': 'James', '1 peter': '1 Peter', '2 peter': '2 Peter',
'1 john': '1 John', '2 john': '2 John', '3 john': '3 John', 'jude': 'Jude', 'revelation': 'Revelation',
'gen': 'Genesis', 'ex': 'Exodus', 'lev': 'Leviticus', 'num': 'Numbers', 'deut': 'Deuteronomy',
'josh': 'Joshua', 'judg': 'Judges', 'ru': 'Ruth', '1sam': '1 Samuel', '2sam': '2 Samuel',
'1ki': '1 Kings', '2ki': '2 Kings', '1chr': '1 Chronicles', '2chr': '2 Chronicles',
'neh': 'Nehemiah', 'est': 'Esther', 'ps': 'Psalms', 'prov': 'Proverbs', 'eccl': 'Ecclesiastes',
'isa': 'Isaiah', 'jer': 'Jeremiah', 'lam': 'Lamentations', 'ezek': 'Ezekiel', 'dan': 'Daniel',
'hos': 'Hosea', 'mic': 'Micah', 'hab': 'Habakkuk', 'zech': 'Zechariah', 'mal': 'Malachi',
'matt': 'Matthew', 'mk': 'Mark', 'lk': 'Luke', 'jn': 'John', 'rom': 'Romans',
'1cor': '1 Corinthians', '2cor': '2 Corinthians', 'gal': 'Galatians', 'eph': 'Ephesians',
'phil': 'Philippians', 'col': 'Colossians', '1thess': '1 Thessalonians', '2thess': '2 Thessalonians',
'1tim': '1 Timothy', '2tim': '2 Timothy', 'tit': 'Titus', 'heb': 'Hebrews', 'jas': 'James',
'1pet': '1 Peter', '2pet': '2 Peter', '1jn': '1 John', '2jn': '2 John', '3jn': '3 John', 'rev': 'Revelation'
};
function capitalizeBook(name) {
return bookMap[name.toLowerCase()] || name;
}
// Try to parse as verse reference and return URL, or null
function parseVerseReference(input) {
// Book Chapter:Verse
var match = input.match(/^(.+)\s+(\d+):(\d+)$/i);
if (match) {
var book = capitalizeBook(match[1].trim());
return '/book/' + encodeURIComponent(book) + '/chapter/' + match[2] + '/verse/' + match[3];
}
// Book Chapter
match = input.match(/^(.+)\s+(\d+)$/i);
if (match) {
var book = capitalizeBook(match[1].trim());
return '/book/' + encodeURIComponent(book) + '/chapter/' + match[2];
}
return null;
}
// Category labels
var categoryLabels = {
books: 'Books',
verses: 'Verses',
topics: 'Topics',
stories: 'Stories',
plans: 'Reading Plans'
};
// Render search results
function renderResults(data) {
var results = data.results;
var html = '';
currentResults = [];
// Check if query looks like a verse reference
var verseUrl = parseVerseReference(data.query);
if (verseUrl) {
html += '<div class="search-results-category">';
html += '<div class="search-results-category-title">Go to</div>';
currentResults.push(verseUrl);
html += '<a href="' + verseUrl + '" class="search-result-item selected">';
html += '<span class="result-title">' + data.query + '</span>';
html += '<span class="result-meta">Press Enter to navigate</span>';
html += '</a></div>';
selectedIndex = 0;
}
if (Object.keys(results).length === 0 && !verseUrl) {
html = '<div class="search-no-results">No results found</div>';
} else {
for (var category in results) {
if (results[category].length > 0) {
html += '<div class="search-results-category">';
html += '<div class="search-results-category-title">' + (categoryLabels[category] || category) + '</div>';
results[category].forEach(function(item) {
var title = item.name || item.title || item.reference;
var meta = '';
if (item.text) meta = item.text;
else if (item.category) meta = item.category;
currentResults.push(item.url);
html += '<a href="' + item.url + '" class="search-result-item">';
html += '<span class="result-title">' + title + '</span>';
if (meta) html += '<span class="result-meta">' + meta + '</span>';
html += '</a>';
});
html += '</div>';
}
}
// Add "View all results" link
html += '<a href="/search?q=' + encodeURIComponent(data.query) + '" class="search-view-all">View all verse results →</a>';
}
dropdown.innerHTML = html;
dropdown.classList.add('show');
if (!verseUrl) selectedIndex = -1;
}
// Perform search
function doSearch(query) {
if (query.length < 2) {
dropdown.classList.remove('show');
return;
}
dropdown.innerHTML = '<div class="search-loading">Searching...</div>';
dropdown.classList.add('show');
fetch('/api/universal-search?q=' + encodeURIComponent(query) + '&limit=4')
.then(function(r) { return r.json(); })
.then(renderResults)
.catch(function() {
dropdown.innerHTML = '<div class="search-no-results">Search error</div>';
});
}
// Input handler with debounce
searchInput.addEventListener('input', function() {
var query = this.value.trim();
clearTimeout(searchTimeout);
if (query.length < 2) {
dropdown.classList.remove('show');
return;
}
searchTimeout = setTimeout(function() {
doSearch(query);
}, 150);
});
// Keyboard navigation
searchInput.addEventListener('keydown', function(e) {
var items = dropdown.querySelectorAll('.search-result-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
updateSelection(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection(items);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && currentResults[selectedIndex]) {
window.location.href = currentResults[selectedIndex];
} else {
// Try verse reference first, then search
var verseUrl = parseVerseReference(this.value.trim());
if (verseUrl) {
window.location.href = verseUrl;
} else if (this.value.trim()) {
window.location.href = '/search?q=' + encodeURIComponent(this.value.trim());
}
}
} else if (e.key === 'Escape') {
dropdown.classList.remove('show');
this.blur();
}
});
// Filter sidebar items as user types
searchInput.addEventListener('input', function() {
var query = this.value.toLowerCase();
var sidebar = document.querySelector('.nav-sidebar');
var links = sidebar.querySelectorAll('a');
// Auto-expand Bible book sections when searching
if (query !== '') {
if (oldTestament) oldTestament.open = true;
if (newTestament) newTestament.open = true;
} else {
// Collapse when search is cleared
if (oldTestament) oldTestament.open = false;
if (newTestament) newTestament.open = false;
}
links.forEach(function(link) {
var text = link.textContent.toLowerCase();
var listItem = link.closest('li');
if (listItem) {
if (query === '' || text.includes(query)) {
listItem.style.display = '';
} else {
listItem.style.display = 'none';
}
}
function updateSelection(items) {
items.forEach(function(item, i) {
item.classList.toggle('selected', i === selectedIndex);
});
}
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.sidebar-search')) {
dropdown.classList.remove('show');
}
});
// Reopen on focus if there's a query
searchInput.addEventListener('focus', function() {
if (this.value.trim().length >= 2) {
doSearch(this.value.trim());
}
});
})();
+339 -82
View File
@@ -110,6 +110,75 @@
text-align: center;
}
/* Search Dropdown */
.verse-lookup {
position: relative;
}
.lookup-results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-color);
border: 1px solid var(--border-color-darker);
border-top: none;
max-height: 400px;
overflow-y: auto;
z-index: 1000;
display: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.lookup-results-dropdown.show {
display: block;
}
.lookup-result-category {
padding: 0.5rem 1rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
background: var(--code-bg);
border-bottom: 1px solid var(--border-color);
}
.lookup-result-item {
display: block;
padding: 0.6rem 1rem;
text-decoration: none;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.15s;
}
.lookup-result-item:hover,
.lookup-result-item.selected {
background: var(--code-bg);
}
.lookup-result-item:last-child {
border-bottom: none;
}
.lookup-result-title {
font-weight: 500;
font-size: 0.95rem;
}
.lookup-result-subtitle {
font-size: 0.8rem;
color: var(--text-tertiary);
margin-top: 0.15rem;
}
[data-theme="dark"] .lookup-results-dropdown {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Navigation Links */
.nav-links {
text-align: center;
@@ -266,6 +335,7 @@
placeholder="Search scripture or jump to verse..." autocomplete="off" />
<button type="submit" class="lookup-btn">Go</button>
</form>
<div id="lookup-results-dropdown" class="lookup-results-dropdown"></div>
<div class="lookup-help">Navigate: John 3:16, Romans 8, Genesis · Search: love, faith, salvation</div>
</section>
@@ -392,120 +462,307 @@
</div>
<script>
var bookMap = {
'genesis': 'Genesis', 'exodus': 'Exodus', 'leviticus': 'Leviticus', 'numbers': 'Numbers',
'deuteronomy': 'Deuteronomy', 'joshua': 'Joshua', 'judges': 'Judges', 'ruth': 'Ruth',
'1 samuel': '1 Samuel', '2 samuel': '2 Samuel', '1 kings': '1 Kings', '2 kings': '2 Kings',
'1 chronicles': '1 Chronicles', '2 chronicles': '2 Chronicles', 'ezra': 'Ezra', 'nehemiah': 'Nehemiah',
'esther': 'Esther', 'job': 'Job', 'psalms': 'Psalms', 'psalm': 'Psalms', 'proverbs': 'Proverbs',
'ecclesiastes': 'Ecclesiastes', 'song of solomon': 'Song of Solomon', 'isaiah': 'Isaiah',
'jeremiah': 'Jeremiah', 'lamentations': 'Lamentations', 'ezekiel': 'Ezekiel', 'daniel': 'Daniel',
'hosea': 'Hosea', 'joel': 'Joel', 'amos': 'Amos', 'obadiah': 'Obadiah', 'jonah': 'Jonah',
'micah': 'Micah', 'nahum': 'Nahum', 'habakkuk': 'Habakkuk', 'zephaniah': 'Zephaniah',
'haggai': 'Haggai', 'zechariah': 'Zechariah', 'malachi': 'Malachi', 'matthew': 'Matthew',
'mark': 'Mark', 'luke': 'Luke', 'john': 'John', 'acts': 'Acts', 'romans': 'Romans',
'1 corinthians': '1 Corinthians', '2 corinthians': '2 Corinthians', 'galatians': 'Galatians',
'ephesians': 'Ephesians', 'philippians': 'Philippians', 'colossians': 'Colossians',
'1 thessalonians': '1 Thessalonians', '2 thessalonians': '2 Thessalonians',
'1 timothy': '1 Timothy', '2 timothy': '2 Timothy', 'titus': 'Titus', 'philemon': 'Philemon',
'hebrews': 'Hebrews', 'james': 'James', '1 peter': '1 Peter', '2 peter': '2 Peter',
'1 john': '1 John', '2 john': '2 John', '3 john': '3 John', 'jude': 'Jude', 'revelation': 'Revelation',
'gen': 'Genesis', 'ge': 'Genesis', 'exo': 'Exodus', 'ex': 'Exodus', 'lev': 'Leviticus',
'le': 'Leviticus', 'num': 'Numbers', 'nu': 'Numbers', 'deut': 'Deuteronomy', 'dt': 'Deuteronomy',
'josh': 'Joshua', 'jos': 'Joshua', 'judg': 'Judges', 'jdg': 'Judges', 'ru': 'Ruth',
'1 sam': '1 Samuel', '1sam': '1 Samuel', '1s': '1 Samuel', '2 sam': '2 Samuel', '2sam': '2 Samuel', '2s': '2 Samuel',
'1 ki': '1 Kings', '1ki': '1 Kings', '1k': '1 Kings', '2 ki': '2 Kings', '2ki': '2 Kings', '2k': '2 Kings',
'1 chr': '1 Chronicles', '1chr': '1 Chronicles', '1ch': '1 Chronicles', '2 chr': '2 Chronicles', '2chr': '2 Chronicles', '2ch': '2 Chronicles',
'ezr': 'Ezra', 'neh': 'Nehemiah', 'ne': 'Nehemiah', 'est': 'Esther', 'ps': 'Psalms', 'psa': 'Psalms',
'prov': 'Proverbs', 'pr': 'Proverbs', 'eccl': 'Ecclesiastes', 'ec': 'Ecclesiastes',
'song': 'Song of Solomon', 'sos': 'Song of Solomon', 'ss': 'Song of Solomon',
'isa': 'Isaiah', 'is': 'Isaiah', 'jer': 'Jeremiah', 'je': 'Jeremiah', 'lam': 'Lamentations', 'la': 'Lamentations',
'ezek': 'Ezekiel', 'eze': 'Ezekiel', 'ezk': 'Ezekiel', 'dan': 'Daniel', 'da': 'Daniel',
'hos': 'Hosea', 'ho': 'Hosea', 'joe': 'Joel', 'jl': 'Joel', 'am': 'Amos', 'ob': 'Obadiah',
'jon': 'Jonah', 'mic': 'Micah', 'mi': 'Micah', 'nah': 'Nahum', 'na': 'Nahum',
'hab': 'Habakkuk', 'hb': 'Habakkuk', 'zep': 'Zephaniah', 'zph': 'Zephaniah',
'hag': 'Haggai', 'hg': 'Haggai', 'zech': 'Zechariah', 'zec': 'Zechariah', 'zch': 'Zechariah',
'mal': 'Malachi', 'mat': 'Matthew', 'mt': 'Matthew', 'mar': 'Mark', 'mk': 'Mark', 'mrk': 'Mark',
'luk': 'Luke', 'lk': 'Luke', 'joh': 'John', 'jn': 'John', 'act': 'Acts', 'ac': 'Acts',
'rom': 'Romans', 'ro': 'Romans', '1 cor': '1 Corinthians', '1cor': '1 Corinthians', '1co': '1 Corinthians',
'2 cor': '2 Corinthians', '2cor': '2 Corinthians', '2co': '2 Corinthians',
'gal': 'Galatians', 'ga': 'Galatians', 'eph': 'Ephesians', 'ep': 'Ephesians',
'phil': 'Philippians', 'php': 'Philippians', 'ph': 'Philippians',
'col': 'Colossians', 'co': 'Colossians', '1 thess': '1 Thessalonians', '1thess': '1 Thessalonians', '1th': '1 Thessalonians',
'2 thess': '2 Thessalonians', '2thess': '2 Thessalonians', '2th': '2 Thessalonians',
'1 tim': '1 Timothy', '1tim': '1 Timothy', '1ti': '1 Timothy', '2 tim': '2 Timothy', '2tim': '2 Timothy', '2ti': '2 Timothy',
'tit': 'Titus', 'ti': 'Titus', 'phm': 'Philemon', 'pm': 'Philemon',
'heb': 'Hebrews', 'he': 'Hebrews', 'jam': 'James', 'jas': 'James', 'jm': 'James',
'1 pet': '1 Peter', '1pet': '1 Peter', '1pe': '1 Peter', '1p': '1 Peter',
'2 pet': '2 Peter', '2pet': '2 Peter', '2pe': '2 Peter', '2p': '2 Peter',
'1 joh': '1 John', '1joh': '1 John', '1jn': '1 John', '2 joh': '2 John', '2joh': '2 John', '2jn': '2 John',
'3 joh': '3 John', '3joh': '3 John', '3jn': '3 John', 'jud': 'Jude',
'rev': 'Revelation', 're': 'Revelation'
};
function capitalizeBook(bookName) {
const bookMap = {
'genesis': 'Genesis', 'exodus': 'Exodus', 'leviticus': 'Leviticus', 'numbers': 'Numbers',
'deuteronomy': 'Deuteronomy', 'joshua': 'Joshua', 'judges': 'Judges', 'ruth': 'Ruth',
'1 samuel': '1 Samuel', '2 samuel': '2 Samuel', '1 kings': '1 Kings', '2 kings': '2 Kings',
'1 chronicles': '1 Chronicles', '2 chronicles': '2 Chronicles', 'ezra': 'Ezra', 'nehemiah': 'Nehemiah',
'esther': 'Esther', 'job': 'Job', 'psalms': 'Psalms', 'psalm': 'Psalms', 'proverbs': 'Proverbs',
'ecclesiastes': 'Ecclesiastes', 'song of solomon': 'Song of Solomon', 'isaiah': 'Isaiah',
'jeremiah': 'Jeremiah', 'lamentations': 'Lamentations', 'ezekiel': 'Ezekiel', 'daniel': 'Daniel',
'hosea': 'Hosea', 'joel': 'Joel', 'amos': 'Amos', 'obadiah': 'Obadiah', 'jonah': 'Jonah',
'micah': 'Micah', 'nahum': 'Nahum', 'habakkuk': 'Habakkuk', 'zephaniah': 'Zephaniah',
'haggai': 'Haggai', 'zechariah': 'Zechariah', 'malachi': 'Malachi', 'matthew': 'Matthew',
'mark': 'Mark', 'luke': 'Luke', 'john': 'John', 'acts': 'Acts', 'romans': 'Romans',
'1 corinthians': '1 Corinthians', '2 corinthians': '2 Corinthians', 'galatians': 'Galatians',
'ephesians': 'Ephesians', 'philippians': 'Philippians', 'colossians': 'Colossians',
'1 thessalonians': '1 Thessalonians', '2 thessalonians': '2 Thessalonians',
'1 timothy': '1 Timothy', '2 timothy': '2 Timothy', 'titus': 'Titus', 'philemon': 'Philemon',
'hebrews': 'Hebrews', 'james': 'James', '1 peter': '1 Peter', '2 peter': '2 Peter',
'1 john': '1 John', '2 john': '2 John', '3 john': '3 John', 'jude': 'Jude', 'revelation': 'Revelation',
'gen': 'Genesis', 'ge': 'Genesis', 'exo': 'Exodus', 'ex': 'Exodus', 'lev': 'Leviticus',
'le': 'Leviticus', 'num': 'Numbers', 'nu': 'Numbers', 'deut': 'Deuteronomy', 'dt': 'Deuteronomy',
'josh': 'Joshua', 'jos': 'Joshua', 'judg': 'Judges', 'jdg': 'Judges', 'ru': 'Ruth',
'1 sam': '1 Samuel', '1sam': '1 Samuel', '1s': '1 Samuel', '2 sam': '2 Samuel', '2sam': '2 Samuel', '2s': '2 Samuel',
'1 ki': '1 Kings', '1ki': '1 Kings', '1k': '1 Kings', '2 ki': '2 Kings', '2ki': '2 Kings', '2k': '2 Kings',
'1 chr': '1 Chronicles', '1chr': '1 Chronicles', '1ch': '1 Chronicles', '2 chr': '2 Chronicles', '2chr': '2 Chronicles', '2ch': '2 Chronicles',
'ezr': 'Ezra', 'neh': 'Nehemiah', 'ne': 'Nehemiah', 'est': 'Esther', 'ps': 'Psalms', 'psa': 'Psalms',
'prov': 'Proverbs', 'pr': 'Proverbs', 'eccl': 'Ecclesiastes', 'ec': 'Ecclesiastes',
'song': 'Song of Solomon', 'sos': 'Song of Solomon', 'ss': 'Song of Solomon',
'isa': 'Isaiah', 'is': 'Isaiah', 'jer': 'Jeremiah', 'je': 'Jeremiah', 'lam': 'Lamentations', 'la': 'Lamentations',
'ezek': 'Ezekiel', 'eze': 'Ezekiel', 'ezk': 'Ezekiel', 'dan': 'Daniel', 'da': 'Daniel',
'hos': 'Hosea', 'ho': 'Hosea', 'joe': 'Joel', 'jl': 'Joel', 'am': 'Amos', 'ob': 'Obadiah',
'jon': 'Jonah', 'mic': 'Micah', 'mi': 'Micah', 'nah': 'Nahum', 'na': 'Nahum',
'hab': 'Habakkuk', 'hb': 'Habakkuk', 'zep': 'Zephaniah', 'zph': 'Zephaniah',
'hag': 'Haggai', 'hg': 'Haggai', 'zech': 'Zechariah', 'zec': 'Zechariah', 'zch': 'Zechariah',
'mal': 'Malachi', 'mat': 'Matthew', 'mt': 'Matthew', 'mar': 'Mark', 'mk': 'Mark', 'mrk': 'Mark',
'luk': 'Luke', 'lk': 'Luke', 'joh': 'John', 'jn': 'John', 'act': 'Acts', 'ac': 'Acts',
'rom': 'Romans', 'ro': 'Romans', '1 cor': '1 Corinthians', '1cor': '1 Corinthians', '1co': '1 Corinthians',
'2 cor': '2 Corinthians', '2cor': '2 Corinthians', '2co': '2 Corinthians',
'gal': 'Galatians', 'ga': 'Galatians', 'eph': 'Ephesians', 'ep': 'Ephesians',
'phil': 'Philippians', 'php': 'Philippians', 'ph': 'Philippians',
'col': 'Colossians', 'co': 'Colossians', '1 thess': '1 Thessalonians', '1thess': '1 Thessalonians', '1th': '1 Thessalonians',
'2 thess': '2 Thessalonians', '2thess': '2 Thessalonians', '2th': '2 Thessalonians',
'1 tim': '1 Timothy', '1tim': '1 Timothy', '1ti': '1 Timothy', '2 tim': '2 Timothy', '2tim': '2 Timothy', '2ti': '2 Timothy',
'tit': 'Titus', 'ti': 'Titus', 'phm': 'Philemon', 'pm': 'Philemon',
'heb': 'Hebrews', 'he': 'Hebrews', 'jam': 'James', 'jas': 'James', 'jm': 'James',
'1 pet': '1 Peter', '1pet': '1 Peter', '1pe': '1 Peter', '1p': '1 Peter',
'2 pet': '2 Peter', '2pet': '2 Peter', '2pe': '2 Peter', '2p': '2 Peter',
'1 joh': '1 John', '1joh': '1 John', '1jn': '1 John', '2 joh': '2 John', '2joh': '2 John', '2jn': '2 John',
'3 joh': '3 John', '3joh': '3 John', '3jn': '3 John', 'jud': 'Jude',
'rev': 'Revelation', 're': 'Revelation'
};
return bookMap[bookName.toLowerCase()] || bookName;
}
// Parse verse reference and return URL or null
function parseVerseReference(input) {
// Book Chapter:Verse
var match = input.match(/^(.+)\s+(\d+):(\d+)$/i);
if (match) {
var book = capitalizeBook(match[1].trim());
return '/book/' + encodeURIComponent(book) + '/chapter/' + match[2] + '/verse/' + match[3];
}
// Book Chapter
match = input.match(/^(.+)\s+(\d+)$/i);
if (match) {
var book = capitalizeBook(match[1].trim());
return '/book/' + encodeURIComponent(book) + '/chapter/' + match[2];
}
return null;
}
// Format verse reference for display
function formatVerseRef(input) {
var match = input.match(/^(.+)\s+(\d+):(\d+)$/i);
if (match) {
return capitalizeBook(match[1].trim()) + ' ' + match[2] + ':' + match[3];
}
match = input.match(/^(.+)\s+(\d+)$/i);
if (match) {
return capitalizeBook(match[1].trim()) + ' ' + match[2];
}
return input;
}
// Dropdown state
var lookupDropdown = null;
var lookupInput = null;
var currentResults = [];
var selectedIndex = -1;
var searchTimeout = null;
var categoryLabels = {
books: 'Books',
verses: 'Verses',
topics: 'Topics',
stories: 'Stories',
plans: 'Reading Plans'
};
function initDropdown() {
lookupDropdown = document.getElementById('lookup-results-dropdown');
lookupInput = document.getElementById('verse-lookup-input');
if (!lookupInput || !lookupDropdown) return;
lookupInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
var query = this.value.trim();
if (query.length < 2) {
hideDropdown();
return;
}
// Check if it's a verse reference first
var verseUrl = parseVerseReference(query);
if (verseUrl) {
showVerseRefResult(query, verseUrl);
}
// Debounce API call
searchTimeout = setTimeout(function() {
performUniversalSearch(query);
}, 200);
});
lookupInput.addEventListener('keydown', function(e) {
if (!lookupDropdown.classList.contains('show')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, currentResults.length - 1);
updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection();
} else if (e.key === 'Escape') {
hideDropdown();
}
});
// Click outside to close
document.addEventListener('click', function(e) {
if (!lookupDropdown.contains(e.target) && e.target !== lookupInput) {
hideDropdown();
}
});
}
function showVerseRefResult(query, url) {
var html = '<div class="lookup-result-category">Go to</div>';
currentResults = [url];
selectedIndex = 0;
html += '<a href="' + url + '" class="lookup-result-item selected">';
html += '<div class="lookup-result-title">' + formatVerseRef(query) + '</div>';
html += '</a>';
lookupDropdown.innerHTML = html;
lookupDropdown.classList.add('show');
}
function performUniversalSearch(query) {
fetch('/api/universal-search?q=' + encodeURIComponent(query) + '&limit=5')
.then(function(r) { return r.json(); })
.then(function(data) {
renderResults(data, query);
})
.catch(function(err) {
console.error('Search error:', err);
});
}
function renderResults(data, query) {
var results = data.results;
var html = '';
var newResults = [];
// If it's a verse reference, show that first
var verseUrl = parseVerseReference(query);
if (verseUrl) {
html += '<div class="lookup-result-category">Go to</div>';
newResults.push(verseUrl);
html += '<a href="' + verseUrl + '" class="lookup-result-item' + (newResults.length === 1 ? ' selected' : '') + '">';
html += '<div class="lookup-result-title">' + formatVerseRef(query) + '</div>';
html += '</a>';
}
// Render each category
var categories = ['books', 'verses', 'topics', 'stories', 'plans'];
categories.forEach(function(cat) {
if (results[cat] && results[cat].length > 0) {
html += '<div class="lookup-result-category">' + categoryLabels[cat] + '</div>';
results[cat].forEach(function(item) {
var url = item.url;
newResults.push(url);
var isSelected = newResults.length === 1 && !verseUrl;
html += '<a href="' + url + '" class="lookup-result-item' + (isSelected ? ' selected' : '') + '">';
if (cat === 'verses') {
html += '<div class="lookup-result-title">' + item.reference + '</div>';
html += '<div class="lookup-result-subtitle">' + item.text + '</div>';
} else if (cat === 'stories') {
html += '<div class="lookup-result-title">' + item.title + '</div>';
if (item.category) {
html += '<div class="lookup-result-subtitle">' + item.category + '</div>';
}
} else {
html += '<div class="lookup-result-title">' + item.name + '</div>';
}
html += '</a>';
});
}
});
currentResults = newResults;
selectedIndex = newResults.length > 0 ? 0 : -1;
if (html) {
lookupDropdown.innerHTML = html;
lookupDropdown.classList.add('show');
} else {
hideDropdown();
}
}
function updateSelection() {
var items = lookupDropdown.querySelectorAll('.lookup-result-item');
items.forEach(function(item, i) {
if (i === selectedIndex) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
}
function hideDropdown() {
if (lookupDropdown) {
lookupDropdown.classList.remove('show');
}
currentResults = [];
selectedIndex = -1;
}
function handleSearch(event) {
event.preventDefault();
const input = document.getElementById('verse-lookup-input').value.trim();
var input = document.getElementById('verse-lookup-input').value.trim();
if (!input) {
return false;
}
// Try to match: Book Chapter:Verse
let match = input.match(/^(.+)\s+(\d+):(\d+)$/i);
if (match) {
const book = capitalizeBook(match[1].trim());
const chapter = match[2];
const verse = match[3];
window.location.href = `/book/${encodeURIComponent(book)}/chapter/${chapter}/verse/${verse}`;
// If dropdown is showing and we have a selection, go there
if (selectedIndex >= 0 && currentResults[selectedIndex]) {
window.location.href = currentResults[selectedIndex];
return false;
}
// Try to match: Book Chapter
match = input.match(/^(.+)\s+(\d+)$/i);
if (match) {
const book = capitalizeBook(match[1].trim());
const chapter = match[2];
window.location.href = `/book/${encodeURIComponent(book)}/chapter/${chapter}`;
// Try verse reference first
var verseUrl = parseVerseReference(input);
if (verseUrl) {
window.location.href = verseUrl;
return false;
}
// Check if input looks like a book name
const hasNumberPrefix = /^[123]\s+/i.test(input);
const hasMultipleWords = /\s+/.test(input);
const commonBooks = ['genesis', 'exodus', 'leviticus', 'numbers', 'deuteronomy',
'joshua', 'judges', 'ruth', 'samuel', 'kings', 'chronicles',
'ezra', 'nehemiah', 'esther', 'job', 'psalms', 'proverbs',
'ecclesiastes', 'isaiah', 'jeremiah', 'lamentations', 'ezekiel',
'daniel', 'hosea', 'joel', 'amos', 'obadiah', 'jonah', 'micah',
'nahum', 'habakkuk', 'zephaniah', 'haggai', 'zechariah', 'malachi',
'matthew', 'mark', 'luke', 'john', 'acts', 'romans', 'corinthians',
'galatians', 'ephesians', 'philippians', 'colossians', 'thessalonians',
'timothy', 'titus', 'philemon', 'hebrews', 'james', 'peter',
'jude', 'revelation'];
const matchesCommonBook = commonBooks.includes(input.toLowerCase());
var hasNumberPrefix = /^[123]\s+/i.test(input);
var hasMultipleWords = /\s+/.test(input);
var commonBooks = ['genesis', 'exodus', 'leviticus', 'numbers', 'deuteronomy',
'joshua', 'judges', 'ruth', 'samuel', 'kings', 'chronicles',
'ezra', 'nehemiah', 'esther', 'job', 'psalms', 'proverbs',
'ecclesiastes', 'isaiah', 'jeremiah', 'lamentations', 'ezekiel',
'daniel', 'hosea', 'joel', 'amos', 'obadiah', 'jonah', 'micah',
'nahum', 'habakkuk', 'zephaniah', 'haggai', 'zechariah', 'malachi',
'matthew', 'mark', 'luke', 'john', 'acts', 'romans', 'corinthians',
'galatians', 'ephesians', 'philippians', 'colossians', 'thessalonians',
'timothy', 'titus', 'philemon', 'hebrews', 'james', 'peter',
'jude', 'revelation'];
var matchesCommonBook = commonBooks.includes(input.toLowerCase());
if (hasNumberPrefix || hasMultipleWords || matchesCommonBook) {
const book = capitalizeBook(input);
window.location.href = `/book/${encodeURIComponent(book)}`;
var book = capitalizeBook(input);
window.location.href = '/book/' + encodeURIComponent(book);
return false;
}
// Otherwise, treat it as a search query
window.location.href = `/search?q=${encodeURIComponent(input)}`;
window.location.href = '/search?q=' + encodeURIComponent(input);
return false;
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', initDropdown);
// Press '/' to focus the search input
document.addEventListener('keydown', function(e) {
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const activeElement = document.activeElement;
var activeElement = document.activeElement;
if (activeElement.tagName !== 'INPUT' && activeElement.tagName !== 'TEXTAREA') {
e.preventDefault();
document.getElementById('verse-lookup-input')?.focus();
document.getElementById('verse-lookup-input').focus();
}
}
});