Skip to content

Commit d777e2b

Browse files
committed
bible verses search
1 parent 818225c commit d777e2b

6 files changed

Lines changed: 259 additions & 3 deletions

File tree

app/assets/stylesheets/components/_forms.scss

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,10 @@ select.form-input {
193193
}
194194

195195
// Autocomplete dropdown styles
196-
[data-topic-autocomplete-target="dropdown"] {
197-
[data-topic-autocomplete-target="results"] {
196+
[data-topic-autocomplete-target="dropdown"],
197+
[data-bible-search-target="dropdown"] {
198+
[data-topic-autocomplete-target="results"],
199+
[data-bible-search-target="results"] {
198200
list-style: none;
199201
margin: 0;
200202
padding: 0;

app/controllers/bible_verses_controller.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,89 @@ def show
8383
redirect_to bible_verse_chapters_path(book: @book), alert: "Verse not found"
8484
end
8585
end
86+
87+
def autocomplete
88+
query = params[:q].to_s.strip
89+
results = []
90+
91+
return render json: [] if query.blank?
92+
93+
query_downcase = query.downcase
94+
95+
# Try to parse the query: book, book chapter, or book chapter:verse
96+
# Patterns: "Genesis", "Genesis 1", "Genesis 1:1", "John 3:16", "Gen 1:1"
97+
book_match = nil
98+
chapter_match = nil
99+
verse_match = nil
100+
101+
# Try to match "book chapter:verse" pattern (e.g., "Genesis 1:1", "John 3:16")
102+
if query.match?(/^(.+?)\s+(\d+):(\d+)$/i)
103+
parts = query.match(/^(.+?)\s+(\d+):(\d+)$/i)
104+
book_match = parts[1].strip
105+
chapter_match = parts[2].to_i
106+
verse_match = parts[3].to_i
107+
# Try to match "book chapter" pattern (e.g., "Genesis 1", "John 3")
108+
elsif query.match?(/^(.+?)\s+(\d+)$/i)
109+
parts = query.match(/^(.+?)\s+(\d+)$/i)
110+
book_match = parts[1].strip
111+
chapter_match = parts[2].to_i
112+
else
113+
# Just book name (e.g., "Genesis", "Gen")
114+
book_match = query.strip
115+
end
116+
117+
# Find matching book names (case-insensitive, partial match)
118+
all_books = OLD_TESTAMENT_BOOKS + NEW_TESTAMENT_BOOKS
119+
matching_books = all_books.select do |book|
120+
book.downcase.start_with?(book_match.downcase) ||
121+
book.downcase.include?(book_match.downcase)
122+
end.sort_by { |book| book.downcase.start_with?(book_match.downcase) ? 0 : 1 }
123+
124+
# If we have book, chapter, and verse, search for exact verse matches
125+
if book_match && chapter_match && verse_match
126+
matching_books.each do |book|
127+
verse = BibleVerse.where("LOWER(book) = ? AND chapter = ? AND verse = ?",
128+
book.downcase, chapter_match, verse_match).first
129+
if verse
130+
results << {
131+
type: "verse",
132+
book: verse.book,
133+
chapter: verse.chapter,
134+
verse: verse.verse
135+
}
136+
break # Only need one match
137+
end
138+
end
139+
end
140+
141+
# If we have book and chapter (but no verse), search for chapter matches
142+
if book_match && chapter_match && !verse_match
143+
matching_books.each do |book|
144+
chapter = BibleVerse.where("LOWER(book) = ? AND chapter = ?",
145+
book.downcase, chapter_match).first
146+
if chapter
147+
results << {
148+
type: "chapter",
149+
book: chapter.book,
150+
chapter: chapter.chapter
151+
}
152+
break # Only need one match
153+
end
154+
end
155+
end
156+
157+
# Always include matching books (if not already in results)
158+
matching_books.first(10).each do |book|
159+
# Skip if this book is already in results
160+
next if results.any? { |r| r[:book] == book }
161+
162+
results << {
163+
type: "book",
164+
book: book
165+
}
166+
end
167+
168+
# Limit total results
169+
render json: results.first(10)
170+
end
86171
end
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["input", "dropdown", "results"]
5+
6+
connect() {
7+
this.searchTimeout = null
8+
this.selectedIndex = -1
9+
this.handleClickOutside = this.handleClickOutside.bind(this)
10+
document.addEventListener('click', this.handleClickOutside)
11+
}
12+
13+
disconnect() {
14+
document.removeEventListener('click', this.handleClickOutside)
15+
}
16+
17+
handleClickOutside(event) {
18+
if (!this.element.contains(event.target)) {
19+
this.hideDropdown()
20+
}
21+
}
22+
23+
search(event) {
24+
clearTimeout(this.searchTimeout)
25+
const query = event.target.value.trim()
26+
27+
if (query.length === 0) {
28+
this.hideDropdown()
29+
return
30+
}
31+
32+
this.searchTimeout = setTimeout(() => {
33+
this.performSearch(query)
34+
}, 300)
35+
}
36+
37+
async performSearch(query) {
38+
try {
39+
const response = await fetch(`/bible_verses/autocomplete?q=${encodeURIComponent(query)}`)
40+
const results = await response.json()
41+
42+
this.displayResults(results, query)
43+
} catch (error) {
44+
console.error("Error fetching Bible search results:", error)
45+
}
46+
}
47+
48+
displayResults(results, query) {
49+
this.resultsTarget.innerHTML = ""
50+
this.selectedIndex = -1
51+
52+
if (results.length === 0) {
53+
this.hideDropdown()
54+
return
55+
}
56+
57+
// Display results
58+
results.forEach((result, index) => {
59+
const li = document.createElement("li")
60+
li.className = "px-4 py-2 hover:bg-gray-100 cursor-pointer"
61+
62+
let displayText = ""
63+
let url = ""
64+
65+
if (result.type === "book") {
66+
displayText = result.book
67+
url = `/bible_verses/${encodeURIComponent(result.book)}/chapters`
68+
} else if (result.type === "chapter") {
69+
displayText = `${result.book} ${result.chapter}`
70+
url = `/bible_verses/${encodeURIComponent(result.book)}/${result.chapter}`
71+
} else if (result.type === "verse") {
72+
displayText = `${result.book} ${result.chapter}:${result.verse}`
73+
url = `/bible_verses/${encodeURIComponent(result.book)}/${result.chapter}/${result.verse}`
74+
}
75+
76+
li.innerHTML = this.escapeHtml(displayText)
77+
li.dataset.action = "click->bible-search#selectResult"
78+
li.dataset.url = url
79+
li.dataset.index = index
80+
this.resultsTarget.appendChild(li)
81+
})
82+
83+
this.showDropdown()
84+
}
85+
86+
selectResult(event) {
87+
const url = event.currentTarget.dataset.url
88+
if (url) {
89+
window.location.href = url
90+
}
91+
}
92+
93+
handleKeydown(event) {
94+
const items = this.resultsTarget.querySelectorAll("li")
95+
96+
if (items.length === 0) return
97+
98+
switch(event.key) {
99+
case "ArrowDown":
100+
event.preventDefault()
101+
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1)
102+
this.highlightItem(items)
103+
break
104+
case "ArrowUp":
105+
event.preventDefault()
106+
this.selectedIndex = Math.max(this.selectedIndex - 1, -1)
107+
this.highlightItem(items)
108+
break
109+
case "Enter":
110+
event.preventDefault()
111+
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
112+
items[this.selectedIndex].click()
113+
}
114+
break
115+
case "Escape":
116+
this.hideDropdown()
117+
break
118+
}
119+
}
120+
121+
highlightItem(items) {
122+
items.forEach((item, index) => {
123+
if (index === this.selectedIndex) {
124+
item.classList.add("bg-gray-100")
125+
} else {
126+
item.classList.remove("bg-gray-100")
127+
}
128+
})
129+
}
130+
131+
showDropdown() {
132+
this.dropdownTarget.classList.remove("hidden")
133+
}
134+
135+
hideDropdown() {
136+
this.dropdownTarget.classList.add("hidden")
137+
this.selectedIndex = -1
138+
}
139+
140+
escapeHtml(text) {
141+
const div = document.createElement("div")
142+
div.textContent = text
143+
return div.innerHTML
144+
}
145+
}

app/javascript/controllers/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import TopicAutocompleteController from "./topic_autocomplete_controller"
77
import TopicsSearchController from "./topics_search_controller"
88
import ThreadsSearchController from "./threads_search_controller"
99
import SearchController from "./search_controller"
10+
import BibleSearchController from "./bible_search_controller"
1011

1112
application.register("modal", ModalController)
1213
application.register("mobile-nav", MobileNavController)
@@ -16,5 +17,6 @@ application.register("topic-autocomplete", TopicAutocompleteController)
1617
application.register("topics-search", TopicsSearchController)
1718
application.register("threads-search", ThreadsSearchController)
1819
application.register("search", SearchController)
20+
application.register("bible-search", BibleSearchController)
1921

2022
export { application }

app/views/bible_verses/book_index.html.erb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,32 @@
44
</div>
55

66
<div>
7-
<div class="d-flex gap-4">
7+
<div class="d-flex gap-4 justify-between items-center">
88
<%= link_to bible_threads_path, class: "btn btn-primary" do %>
99
<i class="fa-solid fa-list-ol"></i>
1010
Browse Bible Threads
1111
<% end %>
12+
13+
<div class="form-group" style="min-width: 300px; max-width: 400px; margin-bottom: 0;" data-controller="bible-search">
14+
<label class="form-label">Search Bible</label>
15+
<div class="relative">
16+
<input
17+
type="text"
18+
class="form-input"
19+
placeholder="Type to search (e.g., Genesis, John 3, Romans 8:1)"
20+
autocomplete="off"
21+
data-bible-search-target="input"
22+
data-action="input->bible-search#search keydown->bible-search#handleKeydown" />
23+
<div
24+
class="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg hidden"
25+
data-bible-search-target="dropdown">
26+
<ul class="max-h-60 overflow-auto py-1 list-none m-0 p-0" data-bible-search-target="results">
27+
<!-- Results will be populated here -->
28+
</ul>
29+
</div>
30+
</div>
31+
<small class="form-help">Start typing to see books, chapters, or verses</small>
32+
</div>
1233
</div>
1334
</div>
1435

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# Bible verses=
1010
get 'bible_verses/books', to: 'bible_verses#book_index'
1111
get 'bible_verses/verse_picker', to: 'bible_verses#verse_picker', as: :bible_verse_picker
12+
get 'bible_verses/autocomplete', to: 'bible_verses#autocomplete', as: :bible_verses_autocomplete
1213
get 'bible_verses/:book/chapters', to: 'bible_verses#chapters', as: :bible_verse_chapters
1314
get 'bible_verses/:book/:chapter', to: 'bible_verses#verses', as: :bible_verse_verses
1415
get 'bible_verses/:book/:chapter/:verse', to: 'bible_verses#show', as: :bible_verse_show

0 commit comments

Comments
 (0)