From 6df0ecda1f0d07285dc880381220b7db6cf504eb Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 26 Nov 2025 10:59:18 -0500 Subject: [PATCH] Add universal search with dropdown to sidebar and homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- kjvstudy_org/routes/api.py | 71 +++++ kjvstudy_org/templates/base.html | 341 +++++++++++++++++++++--- kjvstudy_org/templates/index.html | 421 ++++++++++++++++++++++++------ 3 files changed, 716 insertions(+), 117 deletions(-) diff --git a/kjvstudy_org/routes/api.py b/kjvstudy_org/routes/api.py index 4c39035..441252b 100644 --- a/kjvstudy_org/routes/api.py +++ b/kjvstudy_org/routes/api.py @@ -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.""" diff --git a/kjvstudy_org/templates/base.html b/kjvstudy_org/templates/base.html index db7e329..3dddfa9 100644 --- a/kjvstudy_org/templates/base.html +++ b/kjvstudy_org/templates/base.html @@ -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 @@