feat: implement search-first Bible navigator with touch optimization
Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
36
__tests__/lib/bible-search.test.ts
Normal file
36
__tests__/lib/bible-search.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { searchBooks, parseReference } from '@/lib/bible-search'
|
||||||
|
|
||||||
|
describe('searchBooks', () => {
|
||||||
|
it('returns results for exact book prefix', () => {
|
||||||
|
const results = searchBooks('Genesis')
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results[0].bookName).toBe('Genesis')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses "Book Chapter" format', () => {
|
||||||
|
const results = searchBooks('Genesis 5')
|
||||||
|
expect(results[0].chapter).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works with abbreviations', () => {
|
||||||
|
const results = searchBooks('Gen 1')
|
||||||
|
expect(results[0].bookName).toBe('Genesis')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for empty query', () => {
|
||||||
|
expect(searchBooks('').length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseReference', () => {
|
||||||
|
it('parses full book name with chapter', () => {
|
||||||
|
const result = parseReference('Genesis 3')
|
||||||
|
expect(result?.bookId).toBe(1)
|
||||||
|
expect(result?.chapter).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to chapter 1', () => {
|
||||||
|
const result = parseReference('Genesis')
|
||||||
|
expect(result?.chapter).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
95
components/bible/search-navigator.tsx
Normal file
95
components/bible/search-navigator.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Search, Close } from '@mui/icons-material'
|
||||||
|
import { Box, TextField, InputAdornment, Paper, List, ListItem, ListItemButton, Typography } from '@mui/material'
|
||||||
|
import { searchBooks, type SearchResult } from '@/lib/bible-search'
|
||||||
|
|
||||||
|
interface SearchNavigatorProps {
|
||||||
|
onNavigate: (bookId: number, chapter: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchNavigator({ onNavigate }: SearchNavigatorProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.trim()) {
|
||||||
|
setResults(searchBooks(query))
|
||||||
|
setIsOpen(true)
|
||||||
|
} else {
|
||||||
|
setResults([])
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const handleSelect = (result: SearchResult) => {
|
||||||
|
onNavigate(result.bookId, result.chapter)
|
||||||
|
setQuery('')
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'relative', width: '100%' }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search Bible (e.g., Genesis 1, John 3:16)"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => query && setIsOpen(true)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search sx={{ color: 'text.secondary' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: query && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Close
|
||||||
|
sx={{ cursor: 'pointer', color: 'text.secondary' }}
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
/>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
'@media (max-width: 600px)': {
|
||||||
|
fontSize: '1rem' // Larger on mobile to avoid zoom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && results.length > 0 && (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
mt: 1,
|
||||||
|
maxHeight: 300,
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List>
|
||||||
|
{results.map((result, idx) => (
|
||||||
|
<ListItem key={idx} disablePadding>
|
||||||
|
<ListItemButton onClick={() => handleSelect(result)}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{result.reference}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
lib/bible-search.ts
Normal file
143
lib/bible-search.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// Bible books data with abbreviations
|
||||||
|
const BIBLE_BOOKS = [
|
||||||
|
// Old Testament
|
||||||
|
{ id: 1, name: 'Genesis', abbr: 'Gen', chapters: 50 },
|
||||||
|
{ id: 2, name: 'Exodus', abbr: 'Ex', chapters: 40 },
|
||||||
|
{ id: 3, name: 'Leviticus', abbr: 'Lev', chapters: 27 },
|
||||||
|
{ id: 4, name: 'Numbers', abbr: 'Num', chapters: 36 },
|
||||||
|
{ id: 5, name: 'Deuteronomy', abbr: 'Deut', chapters: 34 },
|
||||||
|
{ id: 6, name: 'Joshua', abbr: 'Josh', chapters: 24 },
|
||||||
|
{ id: 7, name: 'Judges', abbr: 'Judg', chapters: 21 },
|
||||||
|
{ id: 8, name: 'Ruth', abbr: 'Ruth', chapters: 4 },
|
||||||
|
{ id: 9, name: '1 Samuel', abbr: '1Sam', chapters: 31 },
|
||||||
|
{ id: 10, name: '2 Samuel', abbr: '2Sam', chapters: 24 },
|
||||||
|
{ id: 11, name: '1 Kings', abbr: '1Kgs', chapters: 22 },
|
||||||
|
{ id: 12, name: '2 Kings', abbr: '2Kgs', chapters: 25 },
|
||||||
|
{ id: 13, name: '1 Chronicles', abbr: '1Chr', chapters: 29 },
|
||||||
|
{ id: 14, name: '2 Chronicles', abbr: '2Chr', chapters: 36 },
|
||||||
|
{ id: 15, name: 'Ezra', abbr: 'Ezra', chapters: 10 },
|
||||||
|
{ id: 16, name: 'Nehemiah', abbr: 'Neh', chapters: 13 },
|
||||||
|
{ id: 17, name: 'Esther', abbr: 'Esth', chapters: 10 },
|
||||||
|
{ id: 18, name: 'Job', abbr: 'Job', chapters: 42 },
|
||||||
|
{ id: 19, name: 'Psalms', abbr: 'Ps', chapters: 150 },
|
||||||
|
{ id: 20, name: 'Proverbs', abbr: 'Prov', chapters: 31 },
|
||||||
|
{ id: 21, name: 'Ecclesiastes', abbr: 'Eccl', chapters: 12 },
|
||||||
|
{ id: 22, name: 'Song of Solomon', abbr: 'Song', chapters: 8 },
|
||||||
|
{ id: 23, name: 'Isaiah', abbr: 'Isa', chapters: 66 },
|
||||||
|
{ id: 24, name: 'Jeremiah', abbr: 'Jer', chapters: 52 },
|
||||||
|
{ id: 25, name: 'Lamentations', abbr: 'Lam', chapters: 5 },
|
||||||
|
{ id: 26, name: 'Ezekiel', abbr: 'Ezek', chapters: 48 },
|
||||||
|
{ id: 27, name: 'Daniel', abbr: 'Dan', chapters: 12 },
|
||||||
|
{ id: 28, name: 'Hosea', abbr: 'Hos', chapters: 14 },
|
||||||
|
{ id: 29, name: 'Joel', abbr: 'Joel', chapters: 3 },
|
||||||
|
{ id: 30, name: 'Amos', abbr: 'Amos', chapters: 9 },
|
||||||
|
{ id: 31, name: 'Obadiah', abbr: 'Obad', chapters: 1 },
|
||||||
|
{ id: 32, name: 'Jonah', abbr: 'Jonah', chapters: 4 },
|
||||||
|
{ id: 33, name: 'Micah', abbr: 'Mic', chapters: 7 },
|
||||||
|
{ id: 34, name: 'Nahum', abbr: 'Nah', chapters: 3 },
|
||||||
|
{ id: 35, name: 'Habakkuk', abbr: 'Hab', chapters: 3 },
|
||||||
|
{ id: 36, name: 'Zephaniah', abbr: 'Zeph', chapters: 3 },
|
||||||
|
{ id: 37, name: 'Haggai', abbr: 'Hag', chapters: 2 },
|
||||||
|
{ id: 38, name: 'Zechariah', abbr: 'Zech', chapters: 14 },
|
||||||
|
{ id: 39, name: 'Malachi', abbr: 'Mal', chapters: 4 },
|
||||||
|
// New Testament
|
||||||
|
{ id: 40, name: 'Matthew', abbr: 'Matt', chapters: 28 },
|
||||||
|
{ id: 41, name: 'Mark', abbr: 'Mark', chapters: 16 },
|
||||||
|
{ id: 42, name: 'Luke', abbr: 'Luke', chapters: 24 },
|
||||||
|
{ id: 43, name: 'John', abbr: 'John', chapters: 21 },
|
||||||
|
{ id: 44, name: 'Acts', abbr: 'Acts', chapters: 28 },
|
||||||
|
{ id: 45, name: 'Romans', abbr: 'Rom', chapters: 16 },
|
||||||
|
{ id: 46, name: '1 Corinthians', abbr: '1Cor', chapters: 16 },
|
||||||
|
{ id: 47, name: '2 Corinthians', abbr: '2Cor', chapters: 13 },
|
||||||
|
{ id: 48, name: 'Galatians', abbr: 'Gal', chapters: 6 },
|
||||||
|
{ id: 49, name: 'Ephesians', abbr: 'Eph', chapters: 6 },
|
||||||
|
{ id: 50, name: 'Philippians', abbr: 'Phil', chapters: 4 },
|
||||||
|
{ id: 51, name: 'Colossians', abbr: 'Col', chapters: 4 },
|
||||||
|
{ id: 52, name: '1 Thessalonians', abbr: '1Thess', chapters: 5 },
|
||||||
|
{ id: 53, name: '2 Thessalonians', abbr: '2Thess', chapters: 3 },
|
||||||
|
{ id: 54, name: '1 Timothy', abbr: '1Tim', chapters: 6 },
|
||||||
|
{ id: 55, name: '2 Timothy', abbr: '2Tim', chapters: 4 },
|
||||||
|
{ id: 56, name: 'Titus', abbr: 'Titus', chapters: 3 },
|
||||||
|
{ id: 57, name: 'Philemon', abbr: 'Phlm', chapters: 1 },
|
||||||
|
{ id: 58, name: 'Hebrews', abbr: 'Heb', chapters: 13 },
|
||||||
|
{ id: 59, name: 'James', abbr: 'Jas', chapters: 5 },
|
||||||
|
{ id: 60, name: '1 Peter', abbr: '1Pet', chapters: 5 },
|
||||||
|
{ id: 61, name: '2 Peter', abbr: '2Pet', chapters: 3 },
|
||||||
|
{ id: 62, name: '1 John', abbr: '1John', chapters: 5 },
|
||||||
|
{ id: 63, name: '2 John', abbr: '2John', chapters: 1 },
|
||||||
|
{ id: 64, name: '3 John', abbr: '3John', chapters: 1 },
|
||||||
|
{ id: 65, name: 'Jude', abbr: 'Jude', chapters: 1 },
|
||||||
|
{ id: 66, name: 'Revelation', abbr: 'Rev', chapters: 22 }
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
bookId: number
|
||||||
|
bookName: string
|
||||||
|
chapter: number
|
||||||
|
reference: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchBooks(query: string): SearchResult[] {
|
||||||
|
if (!query.trim()) return []
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
const results: SearchResult[] = []
|
||||||
|
|
||||||
|
// Try to parse as "Book Chapter" format (e.g., "Genesis 1", "Gen 1")
|
||||||
|
const refMatch = query.match(/^([a-z\s]+)\s*(\d+)?/i)
|
||||||
|
if (refMatch) {
|
||||||
|
const bookQuery = refMatch[1].toLowerCase().trim()
|
||||||
|
const chapterNum = refMatch[2] ? parseInt(refMatch[2]) : 1
|
||||||
|
|
||||||
|
for (const book of BIBLE_BOOKS) {
|
||||||
|
if (book.name.toLowerCase().startsWith(bookQuery) ||
|
||||||
|
book.abbr.toLowerCase().startsWith(bookQuery)) {
|
||||||
|
if (chapterNum <= book.chapters) {
|
||||||
|
results.push({
|
||||||
|
bookId: book.id,
|
||||||
|
bookName: book.name,
|
||||||
|
chapter: chapterNum,
|
||||||
|
reference: `${book.name} ${chapterNum}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match on book names if exact prefix didn't work
|
||||||
|
if (results.length === 0) {
|
||||||
|
for (const book of BIBLE_BOOKS) {
|
||||||
|
if (book.name.toLowerCase().includes(lowerQuery) ||
|
||||||
|
book.abbr.toLowerCase().includes(lowerQuery)) {
|
||||||
|
results.push({
|
||||||
|
bookId: book.id,
|
||||||
|
bookName: book.name,
|
||||||
|
chapter: 1,
|
||||||
|
reference: book.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, 10) // Return top 10
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReference(ref: string): { bookId: number; chapter: number } | null {
|
||||||
|
const match = ref.match(/^([a-z\s]+)\s*(\d+)?/i)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const bookQuery = match[1].toLowerCase().trim()
|
||||||
|
const chapterNum = match[2] ? parseInt(match[2]) : 1
|
||||||
|
|
||||||
|
for (const book of BIBLE_BOOKS) {
|
||||||
|
if (book.name.toLowerCase().startsWith(bookQuery) ||
|
||||||
|
book.abbr.toLowerCase() === bookQuery) {
|
||||||
|
return {
|
||||||
|
bookId: book.id,
|
||||||
|
chapter: Math.max(1, Math.min(chapterNum, book.chapters))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user