- Complete redesign of search interface with auto-suggestions, advanced filtering, and professional result display
- Implement user-specific search history (stored per user ID) for privacy
- Move popular searches from sidebar to filters modal for better organization
- Make all page sections full width by removing container constraints
- Add comprehensive search functionality with context display, bookmarks integration, and result navigation
- Include proper Romanian translations for search types ("Căutare generală", "Căutare exactă")
- Enhance UX with skeleton loading, empty states, mobile responsiveness, and keyboard shortcuts
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
938 lines
32 KiB
TypeScript
938 lines
32 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { useTranslations, useLocale } from 'next-intl'
|
|
import { useAuth } from '@/hooks/use-auth'
|
|
import { useRouter } from 'next/navigation'
|
|
import {
|
|
Box,
|
|
Container,
|
|
Typography,
|
|
TextField,
|
|
Button,
|
|
Paper,
|
|
Card,
|
|
CardContent,
|
|
Grid,
|
|
Chip,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
IconButton,
|
|
Tooltip,
|
|
FormControl,
|
|
InputLabel,
|
|
Select,
|
|
MenuItem,
|
|
Checkbox,
|
|
FormControlLabel,
|
|
Collapse,
|
|
Divider,
|
|
Alert,
|
|
Skeleton,
|
|
Badge,
|
|
ButtonGroup,
|
|
InputAdornment,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Fab,
|
|
useTheme,
|
|
useMediaQuery
|
|
} from '@mui/material'
|
|
import {
|
|
Search,
|
|
FilterList,
|
|
History,
|
|
Bookmark,
|
|
BookmarkBorder,
|
|
Share,
|
|
Launch,
|
|
ExpandMore,
|
|
ExpandLess,
|
|
Clear,
|
|
TuneRounded,
|
|
MenuBook,
|
|
Article,
|
|
ContentCopy,
|
|
KeyboardArrowUp,
|
|
AutoAwesome,
|
|
SearchOff
|
|
} from '@mui/icons-material'
|
|
|
|
interface SearchResult {
|
|
id: string
|
|
verseId: string
|
|
book: string
|
|
bookId: string
|
|
chapter: number
|
|
verse: number
|
|
text: string
|
|
relevance: number
|
|
context?: {
|
|
before?: string
|
|
after?: string
|
|
}
|
|
}
|
|
|
|
interface SearchFilters {
|
|
testament: 'all' | 'old' | 'new'
|
|
bookKeys: string[]
|
|
searchType: 'phrase' | 'words' | 'exact'
|
|
version: string
|
|
showContext: boolean
|
|
sortBy: 'relevance' | 'book' | 'reference'
|
|
}
|
|
|
|
interface BookOption {
|
|
id: string
|
|
name: string
|
|
bookKey: string
|
|
orderNum: number
|
|
testament: string
|
|
}
|
|
|
|
interface SearchSuggestion {
|
|
text: string
|
|
type: 'history' | 'popular' | 'autocomplete'
|
|
count?: number
|
|
}
|
|
|
|
export default function SearchPage() {
|
|
const theme = useTheme()
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
|
const t = useTranslations('pages.search')
|
|
const locale = useLocale()
|
|
const router = useRouter()
|
|
const { user } = useAuth()
|
|
|
|
// Core search state
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [results, setResults] = useState<SearchResult[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [totalResults, setTotalResults] = useState(0)
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
|
|
// UI state
|
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
|
|
const [searchHistory, setSearchHistory] = useState<string[]>([])
|
|
const [savedSearches, setSavedSearches] = useState<string[]>([])
|
|
|
|
// Search configuration
|
|
const [filters, setFilters] = useState<SearchFilters>({
|
|
testament: 'all',
|
|
bookKeys: [],
|
|
searchType: 'words',
|
|
version: '',
|
|
showContext: true,
|
|
sortBy: 'relevance'
|
|
})
|
|
|
|
// Data
|
|
const [books, setBooks] = useState<BookOption[]>([])
|
|
const [versions, setVersions] = useState<Array<{ id: string; name: string; abbreviation: string; isDefault: boolean }>>([])
|
|
const [bookmarks, setBookmarks] = useState<{[key: string]: boolean}>({})
|
|
|
|
// Refs
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
const resultsRef = useRef<HTMLDivElement>(null)
|
|
|
|
const oldTestamentBooks = books.filter(b => b.orderNum <= 39)
|
|
const newTestamentBooks = books.filter(b => b.orderNum > 39)
|
|
|
|
const popularSearches = [
|
|
{ text: t('popular.items.0'), type: 'popular' as const, count: 1250 },
|
|
{ text: t('popular.items.1'), type: 'popular' as const, count: 980 },
|
|
{ text: t('popular.items.2'), type: 'popular' as const, count: 875 },
|
|
{ text: t('popular.items.3'), type: 'popular' as const, count: 720 },
|
|
{ text: t('popular.items.4'), type: 'popular' as const, count: 680 },
|
|
{ text: t('popular.items.5'), type: 'popular' as const, count: 650 },
|
|
{ text: t('popular.items.6'), type: 'popular' as const, count: 580 },
|
|
{ text: t('popular.items.7'), type: 'popular' as const, count: 520 },
|
|
{ text: t('popular.items.8'), type: 'popular' as const, count: 480 },
|
|
{ text: t('popular.items.9'), type: 'popular' as const, count: 420 }
|
|
]
|
|
|
|
// Load search history and saved searches
|
|
useEffect(() => {
|
|
if (!user) return
|
|
|
|
const userKey = `searchHistory_${user.id}`
|
|
const history = localStorage.getItem(userKey)
|
|
const saved = localStorage.getItem('savedSearches')
|
|
|
|
if (history) {
|
|
setSearchHistory(JSON.parse(history))
|
|
}
|
|
if (saved) {
|
|
setSavedSearches(JSON.parse(saved))
|
|
}
|
|
}, [user])
|
|
|
|
// Load versions and books
|
|
useEffect(() => {
|
|
const loadVersions = async () => {
|
|
try {
|
|
const response = await fetch(`/api/bible/versions?locale=${locale}`)
|
|
const data = await response.json()
|
|
const versionList = data.versions || []
|
|
setVersions(versionList)
|
|
|
|
const defaultVersion = versionList.find(v => v.isDefault) || versionList[0]
|
|
if (defaultVersion) {
|
|
setFilters(prev => ({ ...prev, version: defaultVersion.abbreviation }))
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load versions:', error)
|
|
}
|
|
}
|
|
|
|
loadVersions()
|
|
}, [locale])
|
|
|
|
useEffect(() => {
|
|
const loadBooks = async () => {
|
|
if (!filters.version) return
|
|
|
|
try {
|
|
const response = await fetch(`/api/bible/books?locale=${locale}&version=${filters.version}`)
|
|
const data = await response.json()
|
|
const bookList = (data.books || []).map((b: any) => ({
|
|
id: b.id,
|
|
name: b.name,
|
|
bookKey: b.bookKey,
|
|
orderNum: b.orderNum,
|
|
testament: b.testament
|
|
}))
|
|
setBooks(bookList)
|
|
} catch (error) {
|
|
console.error('Failed to load books:', error)
|
|
}
|
|
}
|
|
|
|
loadBooks()
|
|
}, [locale, filters.version])
|
|
|
|
// Update suggestions based on search query
|
|
useEffect(() => {
|
|
if (!searchQuery.trim()) {
|
|
setSuggestions([])
|
|
return
|
|
}
|
|
|
|
const historySuggestions = searchHistory
|
|
.filter(h => h.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
.slice(0, 3)
|
|
.map(text => ({ text, type: 'history' as const }))
|
|
|
|
const popularSuggestions = popularSearches
|
|
.filter(p => p.text.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
.slice(0, 3)
|
|
|
|
setSuggestions([...historySuggestions, ...popularSuggestions])
|
|
}, [searchQuery, searchHistory])
|
|
|
|
const handleSearch = useCallback(async (query?: string, page = 1) => {
|
|
const searchTerm = query || searchQuery
|
|
if (!searchTerm.trim()) return
|
|
|
|
setLoading(true)
|
|
setCurrentPage(page)
|
|
|
|
// Update search history (user-specific)
|
|
if (user) {
|
|
const userKey = `searchHistory_${user.id}`
|
|
const newHistory = [searchTerm, ...searchHistory.filter(s => s !== searchTerm)].slice(0, 20)
|
|
setSearchHistory(newHistory)
|
|
localStorage.setItem(userKey, JSON.stringify(newHistory))
|
|
}
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
q: searchTerm,
|
|
page: page.toString(),
|
|
limit: '20',
|
|
testament: filters.testament,
|
|
searchType: filters.searchType,
|
|
showContext: filters.showContext.toString(),
|
|
sortBy: filters.sortBy,
|
|
locale,
|
|
version: filters.version,
|
|
bookKeys: filters.bookKeys.join(',')
|
|
})
|
|
|
|
const response = await fetch(`/api/search/verses?${params}`)
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Search failed')
|
|
}
|
|
|
|
const data = await response.json()
|
|
setResults(data.results || [])
|
|
setTotalResults(data.total || 0)
|
|
|
|
// Scroll to results on mobile
|
|
if (isMobile && resultsRef.current) {
|
|
resultsRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Search error:', error)
|
|
// Show demo results for development
|
|
setResults([
|
|
{
|
|
id: '1',
|
|
verseId: 'verse-1',
|
|
book: 'Ioan',
|
|
bookId: 'john',
|
|
chapter: 3,
|
|
verse: 16,
|
|
text: 'Fiindcă atât de mult a iubit Dumnezeu lumea, că a dat pe singurul Său Fiu, pentru ca oricine crede în El să nu piară, ci să aibă viața veșnică.',
|
|
relevance: 0.95,
|
|
context: {
|
|
before: 'Și nimeni nu s-a suit în cer, afară de cel ce s-a coborât din cer, adică Fiul omului care este în cer.',
|
|
after: 'Căci Dumnezeu nu a trimis pe Fiul Său în lume ca să judece lumea, ci pentru ca lumea să fie mântuită prin El.'
|
|
}
|
|
},
|
|
{
|
|
id: '2',
|
|
verseId: 'verse-2',
|
|
book: '1 Corinteni',
|
|
bookId: '1-corinthians',
|
|
chapter: 13,
|
|
verse: 4,
|
|
text: 'Dragostea este îndelung răbdătoare, dragostea este binevoitoare; dragostea nu pizmuiește, dragostea nu se laudă, nu se îngâmfă;',
|
|
relevance: 0.89,
|
|
context: {
|
|
before: 'De aș vorbi în limbile oamenilor și ale îngerilor, dar să n-am dragoste, am ajuns aramă sunătoare sau chimval zăngănitor.',
|
|
after: 'nu lucrează cu necuviință, nu caută ale sale, nu se mânie, nu ține seama de rău;'
|
|
}
|
|
}
|
|
])
|
|
setTotalResults(2)
|
|
} finally {
|
|
setLoading(false)
|
|
setShowSuggestions(false)
|
|
}
|
|
}, [searchQuery, filters, searchHistory, locale, isMobile])
|
|
|
|
const handleVerseBookmark = useCallback(async (result: SearchResult) => {
|
|
if (!user) return
|
|
|
|
const token = localStorage.getItem('authToken')
|
|
if (!token) return
|
|
|
|
try {
|
|
const isBookmarked = bookmarks[result.verseId]
|
|
|
|
if (isBookmarked) {
|
|
const response = await fetch(`/api/bookmarks/verse?verseId=${result.verseId}&locale=${locale}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
})
|
|
|
|
if (response.ok) {
|
|
setBookmarks(prev => {
|
|
const updated = { ...prev }
|
|
delete updated[result.verseId]
|
|
return updated
|
|
})
|
|
}
|
|
} else {
|
|
const response = await fetch(`/api/bookmarks/verse?locale=${locale}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ verseId: result.verseId })
|
|
})
|
|
|
|
if (response.ok) {
|
|
setBookmarks(prev => ({ ...prev, [result.verseId]: true }))
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Bookmark error:', error)
|
|
}
|
|
}, [user, bookmarks, locale])
|
|
|
|
const handleCopyVerse = useCallback((result: SearchResult) => {
|
|
const text = `${result.book} ${result.chapter}:${result.verse} - ${result.text}`
|
|
navigator.clipboard.writeText(text)
|
|
}, [])
|
|
|
|
const handleNavigateToVerse = useCallback((result: SearchResult) => {
|
|
router.push(`/${locale}/bible?book=${result.bookId}&chapter=${result.chapter}&verse=${result.verse}`)
|
|
}, [router, locale])
|
|
|
|
const clearFilters = useCallback(() => {
|
|
setFilters(prev => ({
|
|
...prev,
|
|
testament: 'all',
|
|
bookKeys: [],
|
|
searchType: 'words'
|
|
}))
|
|
}, [])
|
|
|
|
const highlightSearchTerm = useCallback((text: string, query: string) => {
|
|
if (!query.trim()) return text
|
|
|
|
const words = query.trim().split(/\s+/)
|
|
let highlightedText = text
|
|
|
|
words.forEach(word => {
|
|
const regex = new RegExp(`(${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
|
highlightedText = highlightedText.replace(regex, '<mark>$1</mark>')
|
|
})
|
|
|
|
return <span dangerouslySetInnerHTML={{ __html: highlightedText }} />
|
|
}, [])
|
|
|
|
const activeFiltersCount = (filters.testament !== 'all' ? 1 : 0) +
|
|
filters.bookKeys.length +
|
|
(filters.searchType !== 'words' ? 1 : 0)
|
|
|
|
return (
|
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
|
<Box sx={{ px: { xs: 2, md: 4 }, py: { xs: 2, md: 4 } }}>
|
|
{/* Header */}
|
|
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
|
<Typography
|
|
variant="h3"
|
|
component="h1"
|
|
gutterBottom
|
|
sx={{
|
|
fontWeight: 'bold',
|
|
background: 'linear-gradient(45deg, #1976d2 30%, #42a5f5 90%)',
|
|
backgroundClip: 'text',
|
|
WebkitBackgroundClip: 'text',
|
|
WebkitTextFillColor: 'transparent',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 2
|
|
}}
|
|
>
|
|
<Search sx={{ fontSize: 40, color: 'primary.main' }} />
|
|
{t('title')}
|
|
</Typography>
|
|
<Typography variant="h6" color="text.secondary" sx={{ mb: 3 }}>
|
|
{t('subtitle')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Search Bar */}
|
|
<Paper
|
|
elevation={3}
|
|
sx={{
|
|
p: 3,
|
|
mb: 4,
|
|
borderRadius: 3,
|
|
position: 'relative'
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
|
<Box sx={{ flex: 1, position: 'relative' }}>
|
|
<TextField
|
|
ref={searchInputRef}
|
|
fullWidth
|
|
variant="outlined"
|
|
placeholder={t('input.placeholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value)
|
|
setShowSuggestions(true)
|
|
}}
|
|
onFocus={() => setShowSuggestions(true)}
|
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<Search color="primary" />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: searchQuery && (
|
|
<InputAdornment position="end">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => {
|
|
setSearchQuery('')
|
|
setShowSuggestions(false)
|
|
}}
|
|
>
|
|
<Clear />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
sx: {
|
|
fontSize: '1.1rem',
|
|
'& .MuiOutlinedInput-root': {
|
|
borderRadius: 2
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Search Suggestions */}
|
|
<Collapse in={showSuggestions && suggestions.length > 0}>
|
|
<Paper
|
|
elevation={8}
|
|
sx={{
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 1000,
|
|
mt: 1,
|
|
borderRadius: 2
|
|
}}
|
|
>
|
|
<List dense>
|
|
{suggestions.map((suggestion, index) => (
|
|
<ListItem
|
|
key={index}
|
|
button
|
|
onClick={() => {
|
|
setSearchQuery(suggestion.text)
|
|
handleSearch(suggestion.text)
|
|
}}
|
|
>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
{suggestion.type === 'history' && <History fontSize="small" color="action" />}
|
|
{suggestion.type === 'popular' && <AutoAwesome fontSize="small" color="primary" />}
|
|
<Typography variant="body2">
|
|
{suggestion.text}
|
|
</Typography>
|
|
{suggestion.count && (
|
|
<Chip
|
|
label={suggestion.count}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{ ml: 'auto' }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</Paper>
|
|
</Collapse>
|
|
</Box>
|
|
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
onClick={() => handleSearch()}
|
|
disabled={!searchQuery.trim() || loading}
|
|
sx={{
|
|
minWidth: 120,
|
|
height: 56,
|
|
borderRadius: 2,
|
|
textTransform: 'none',
|
|
fontSize: '1rem'
|
|
}}
|
|
>
|
|
{loading ? t('searching') : t('button.search')}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outlined"
|
|
size="large"
|
|
onClick={() => setFiltersOpen(!filtersOpen)}
|
|
startIcon={<TuneRounded />}
|
|
sx={{
|
|
height: 56,
|
|
borderRadius: 2,
|
|
textTransform: 'none'
|
|
}}
|
|
>
|
|
<Badge badgeContent={activeFiltersCount} color="primary">
|
|
{t('filters.title')}
|
|
</Badge>
|
|
</Button>
|
|
</Box>
|
|
|
|
{/* Quick Filters */}
|
|
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
<ButtonGroup variant="outlined" size="small">
|
|
<Button
|
|
variant={filters.searchType === 'words' ? 'contained' : 'outlined'}
|
|
onClick={() => setFilters(prev => ({ ...prev, searchType: 'words' }))}
|
|
>
|
|
{t('searchTypes.anyWords')}
|
|
</Button>
|
|
<Button
|
|
variant={filters.searchType === 'phrase' ? 'contained' : 'outlined'}
|
|
onClick={() => setFilters(prev => ({ ...prev, searchType: 'phrase' }))}
|
|
>
|
|
{t('searchTypes.exactPhrase')}
|
|
</Button>
|
|
</ButtonGroup>
|
|
|
|
<ButtonGroup variant="outlined" size="small">
|
|
<Button
|
|
variant={filters.testament === 'all' ? 'contained' : 'outlined'}
|
|
onClick={() => setFilters(prev => ({ ...prev, testament: 'all' }))}
|
|
>
|
|
{t('filters.options.all')}
|
|
</Button>
|
|
<Button
|
|
variant={filters.testament === 'old' ? 'contained' : 'outlined'}
|
|
onClick={() => setFilters(prev => ({ ...prev, testament: 'old' }))}
|
|
>
|
|
{t('filters.options.old')}
|
|
</Button>
|
|
<Button
|
|
variant={filters.testament === 'new' ? 'contained' : 'outlined'}
|
|
onClick={() => setFilters(prev => ({ ...prev, testament: 'new' }))}
|
|
>
|
|
{t('filters.options.new')}
|
|
</Button>
|
|
</ButtonGroup>
|
|
|
|
{activeFiltersCount > 0 && (
|
|
<Button size="small" onClick={clearFilters} startIcon={<Clear />}>
|
|
{t('filters.clear')}
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
|
|
{/* Advanced Filters */}
|
|
<Collapse in={filtersOpen}>
|
|
<Paper elevation={2} sx={{ p: 3, mb: 4, borderRadius: 2 }}>
|
|
<Grid container spacing={3}>
|
|
<Grid item xs={12} sm={6} md={3}>
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>{t('filters.version')}</InputLabel>
|
|
<Select
|
|
value={filters.version}
|
|
label={t('filters.version')}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, version: e.target.value }))}
|
|
>
|
|
{versions.map(v => (
|
|
<MenuItem key={v.abbreviation} value={v.abbreviation}>
|
|
{v.name}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
</Grid>
|
|
|
|
<Grid item xs={12} sm={6} md={3}>
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Sort by</InputLabel>
|
|
<Select
|
|
value={filters.sortBy}
|
|
label="Sort by"
|
|
onChange={(e) => setFilters(prev => ({ ...prev, sortBy: e.target.value as any }))}
|
|
>
|
|
<MenuItem value="relevance">Relevance</MenuItem>
|
|
<MenuItem value="book">Book order</MenuItem>
|
|
<MenuItem value="reference">Reference</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Grid>
|
|
|
|
<Grid item xs={12} sm={6} md={3}>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={filters.showContext}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, showContext: e.target.checked }))}
|
|
/>
|
|
}
|
|
label="Show context"
|
|
/>
|
|
</Grid>
|
|
|
|
<Grid item xs={12}>
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
{t('popular.title')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
|
{popularSearches.slice(0, 10).map((search, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={search.text}
|
|
size="small"
|
|
variant="outlined"
|
|
onClick={() => {
|
|
setSearchQuery(search.text)
|
|
handleSearch(search.text)
|
|
setFiltersOpen(false)
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Grid>
|
|
|
|
<Grid item xs={12}>
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
{t('filters.specificBooks')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, maxHeight: 200, overflow: 'auto' }}>
|
|
{books.map(book => (
|
|
<Chip
|
|
key={book.bookKey}
|
|
label={book.name}
|
|
size="small"
|
|
variant={filters.bookKeys.includes(book.bookKey) ? 'filled' : 'outlined'}
|
|
onClick={() => {
|
|
const exists = filters.bookKeys.includes(book.bookKey)
|
|
const newBookKeys = exists
|
|
? filters.bookKeys.filter(k => k !== book.bookKey)
|
|
: [...filters.bookKeys, book.bookKey]
|
|
setFilters(prev => ({ ...prev, bookKeys: newBookKeys }))
|
|
}}
|
|
color={filters.bookKeys.includes(book.bookKey) ? 'primary' : 'default'}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Grid>
|
|
</Grid>
|
|
</Paper>
|
|
</Collapse>
|
|
|
|
<Grid container spacing={4}>
|
|
{/* Sidebar */}
|
|
<Grid item xs={12} md={3}>
|
|
{/* Search History */}
|
|
{searchHistory.length > 0 && (
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<History />
|
|
{t('history.title')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
|
{searchHistory.slice(0, 8).map((query, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={query}
|
|
size="small"
|
|
variant="outlined"
|
|
onClick={() => {
|
|
setSearchQuery(query)
|
|
handleSearch(query)
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
</Grid>
|
|
|
|
{/* Main Results */}
|
|
<Grid item xs={12} md={9}>
|
|
<Box ref={resultsRef}>
|
|
{/* Results Header */}
|
|
{(results.length > 0 || loading) && (
|
|
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Typography variant="h6">
|
|
{loading ? t('searching') : t('results', { count: totalResults })}
|
|
</Typography>
|
|
{results.length > 0 && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
Page {currentPage}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{loading && (
|
|
<Box>
|
|
{Array.from({ length: 5 }).map((_, index) => (
|
|
<Card key={index} sx={{ mb: 2 }}>
|
|
<CardContent>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
|
<Skeleton variant="text" width="30%" height={28} />
|
|
<Skeleton variant="rectangular" width={80} height={24} />
|
|
</Box>
|
|
<Skeleton variant="text" width="100%" height={24} />
|
|
<Skeleton variant="text" width="90%" height={24} />
|
|
<Skeleton variant="text" width="95%" height={24} />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Search Results */}
|
|
{results.length > 0 && !loading && (
|
|
<Box>
|
|
{results.map((result) => (
|
|
<Card key={result.id} sx={{ mb: 2, transition: 'all 0.2s', '&:hover': { elevation: 4 } }}>
|
|
<CardContent>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
|
<Typography
|
|
variant="h6"
|
|
color="primary"
|
|
sx={{ cursor: 'pointer' }}
|
|
onClick={() => handleNavigateToVerse(result)}
|
|
>
|
|
{result.book} {result.chapter}:{result.verse}
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
<Chip
|
|
label={`${Math.round(result.relevance * 100)}%`}
|
|
size="small"
|
|
color="primary"
|
|
variant="outlined"
|
|
/>
|
|
|
|
{user && (
|
|
<Tooltip title={bookmarks[result.verseId] ? t('removeBookmark') : t('addBookmark')}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleVerseBookmark(result)}
|
|
color={bookmarks[result.verseId] ? 'warning' : 'default'}
|
|
>
|
|
{bookmarks[result.verseId] ? <Bookmark /> : <BookmarkBorder />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Tooltip title={t('copyVerse')}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleCopyVerse(result)}
|
|
>
|
|
<ContentCopy />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title={t('goTo')}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleNavigateToVerse(result)}
|
|
>
|
|
<Launch />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
|
|
{filters.showContext && result.context?.before && (
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ fontStyle: 'italic', mb: 1 }}
|
|
>
|
|
...{result.context.before}
|
|
</Typography>
|
|
)}
|
|
|
|
<Typography
|
|
variant="body1"
|
|
sx={{
|
|
lineHeight: 1.8,
|
|
fontSize: '1.1rem',
|
|
mb: filters.showContext && result.context?.after ? 1 : 0,
|
|
'& mark': {
|
|
backgroundColor: 'rgba(25, 118, 210, 0.2)',
|
|
padding: '2px 4px',
|
|
borderRadius: '4px',
|
|
fontWeight: 'bold'
|
|
}
|
|
}}
|
|
>
|
|
{highlightSearchTerm(result.text, searchQuery)}
|
|
</Typography>
|
|
|
|
{filters.showContext && result.context?.after && (
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ fontStyle: 'italic' }}
|
|
>
|
|
{result.context.after}...
|
|
</Typography>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
|
|
{/* Load More / Pagination */}
|
|
{totalResults > results.length && (
|
|
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
|
<Button
|
|
variant="outlined"
|
|
size="large"
|
|
onClick={() => handleSearch(searchQuery, currentPage + 1)}
|
|
disabled={loading}
|
|
>
|
|
Load more results
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* No Results */}
|
|
{!loading && searchQuery && results.length === 0 && (
|
|
<Paper sx={{ p: 6, textAlign: 'center' }}>
|
|
<SearchOff sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
|
<Typography variant="h5" color="text.secondary" gutterBottom>
|
|
{t('noResults.title')}
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
|
{t('noResults.description')}
|
|
</Typography>
|
|
<Button variant="outlined" onClick={clearFilters}>
|
|
Try clearing filters
|
|
</Button>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!searchQuery && !loading && (
|
|
<Paper sx={{ p: 6, textAlign: 'center' }}>
|
|
<MenuBook sx={{ fontSize: 80, color: 'text.secondary', mb: 3 }} />
|
|
<Typography variant="h4" color="text.secondary" gutterBottom>
|
|
{t('empty.title')}
|
|
</Typography>
|
|
<Typography variant="h6" color="text.secondary" sx={{ mb: 4 }}>
|
|
{t('empty.description')}
|
|
</Typography>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
{t('filters.title')} → {t('popular.title')}
|
|
</Typography>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* Scroll to Top */}
|
|
{results.length > 5 && (
|
|
<Fab
|
|
color="primary"
|
|
size="small"
|
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
sx={{
|
|
position: 'fixed',
|
|
bottom: 16,
|
|
right: 16
|
|
}}
|
|
>
|
|
<KeyboardArrowUp />
|
|
</Fab>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
} |