490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
'use client'
|
|
import {
|
|
Container,
|
|
Grid,
|
|
Card,
|
|
CardContent,
|
|
Typography,
|
|
Box,
|
|
TextField,
|
|
Button,
|
|
Paper,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
Chip,
|
|
InputAdornment,
|
|
FormControl,
|
|
InputLabel,
|
|
Select,
|
|
MenuItem,
|
|
Accordion,
|
|
AccordionSummary,
|
|
AccordionDetails,
|
|
useTheme,
|
|
CircularProgress,
|
|
Skeleton,
|
|
} from '@mui/material'
|
|
import {
|
|
Search,
|
|
FilterList,
|
|
ExpandMore,
|
|
MenuBook,
|
|
Close,
|
|
History,
|
|
} from '@mui/icons-material'
|
|
import { useState, useEffect } from 'react'
|
|
import { useTranslations, useLocale } from 'next-intl'
|
|
|
|
interface SearchResult {
|
|
id: string
|
|
book: string
|
|
chapter: number
|
|
verse: number
|
|
text: string
|
|
relevance: number
|
|
}
|
|
|
|
interface SearchFilter {
|
|
testament: 'all' | 'old' | 'new'
|
|
bookKeys: string[]
|
|
exactMatch: boolean
|
|
}
|
|
|
|
interface BookOption {
|
|
id: string
|
|
name: string
|
|
bookKey: string
|
|
orderNum: number
|
|
testament: string
|
|
}
|
|
|
|
export default function SearchPage() {
|
|
const theme = useTheme()
|
|
const t = useTranslations('pages.search')
|
|
const locale = useLocale()
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [results, setResults] = useState<SearchResult[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [searchHistory, setSearchHistory] = useState<string[]>([])
|
|
const [filters, setFilters] = useState<SearchFilter>({ testament: 'all', bookKeys: [], exactMatch: false })
|
|
const [booksData, setBooksData] = useState<BookOption[]>([])
|
|
const [versions, setVersions] = useState<Array<{ id: string; name: string; abbreviation: string; isDefault: boolean }>>([])
|
|
const [selectedVersion, setSelectedVersion] = useState<string>('')
|
|
|
|
const oldTestamentBooks = booksData.filter(b => b.orderNum <= 39)
|
|
const newTestamentBooks = booksData.filter(b => b.orderNum > 39)
|
|
|
|
const popularSearches: string[] = t.raw('popular.items')
|
|
|
|
useEffect(() => {
|
|
// Load search history from localStorage
|
|
const saved = localStorage.getItem('searchHistory')
|
|
if (saved) {
|
|
setSearchHistory(JSON.parse(saved))
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
// Fetch available versions for locale
|
|
fetch(`/api/bible/versions?locale=${locale}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
const list = (data.versions || []) as Array<{ id: string; name: string; abbreviation: string; isDefault: boolean }>
|
|
setVersions(list)
|
|
const def = list.find(v => v.isDefault) || list[0]
|
|
setSelectedVersion(def?.abbreviation || '')
|
|
})
|
|
.catch(() => {
|
|
setVersions([])
|
|
setSelectedVersion('')
|
|
})
|
|
}, [locale])
|
|
|
|
useEffect(() => {
|
|
if (!selectedVersion && versions.length === 0) return
|
|
// Fetch available books for current locale/version
|
|
const qs = new URLSearchParams({ locale, ...(selectedVersion ? { version: selectedVersion } : {}) })
|
|
fetch(`/api/bible/books?${qs}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
const mapped: BookOption[] = (data.books || []).map((b: any) => ({
|
|
id: b.id,
|
|
name: b.name,
|
|
bookKey: b.bookKey,
|
|
orderNum: b.orderNum,
|
|
testament: b.testament,
|
|
}))
|
|
setBooksData(mapped)
|
|
})
|
|
.catch(() => setBooksData([]))
|
|
}, [locale, selectedVersion, versions.length])
|
|
|
|
const handleSearch = async () => {
|
|
if (!searchQuery.trim()) return
|
|
|
|
setLoading(true)
|
|
|
|
// Add to search history
|
|
const newHistory = [searchQuery, ...searchHistory.filter(s => s !== searchQuery)].slice(0, 10)
|
|
setSearchHistory(newHistory)
|
|
localStorage.setItem('searchHistory', JSON.stringify(newHistory))
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
q: searchQuery,
|
|
testament: filters.testament,
|
|
exactMatch: filters.exactMatch.toString(),
|
|
bookKeys: filters.bookKeys.join(','),
|
|
locale,
|
|
...(selectedVersion ? { version: selectedVersion } : {}),
|
|
})
|
|
|
|
const response = await fetch(`/api/search/verses?${params}`)
|
|
if (!response.ok) {
|
|
throw new Error('Search failed')
|
|
}
|
|
|
|
const data = await response.json()
|
|
setResults(data.results || [])
|
|
} catch (error) {
|
|
console.error('Error searching:', error)
|
|
// Mock results for demo
|
|
setResults([
|
|
{
|
|
id: '1',
|
|
book: 'Ioan',
|
|
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,
|
|
},
|
|
{
|
|
id: '2',
|
|
book: '1 Corinteni',
|
|
chapter: 13,
|
|
verse: 4,
|
|
text: 'Dragostea este îndelung răbdătoare, dragostea este binevoitoare; dragostea nu pizmuiește...',
|
|
relevance: 0.89,
|
|
},
|
|
])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleKeyPress = (event: React.KeyboardEvent) => {
|
|
if (event.key === 'Enter') {
|
|
handleSearch()
|
|
}
|
|
}
|
|
|
|
const clearFilters = () => {
|
|
setFilters({
|
|
testament: 'all',
|
|
books: [],
|
|
exactMatch: false,
|
|
})
|
|
}
|
|
|
|
const highlightSearchTerm = (text: string, query: string) => {
|
|
if (!query) return text
|
|
|
|
const regex = new RegExp(`(${query})`, 'gi')
|
|
const parts = text.split(regex)
|
|
|
|
return parts.map((part, index) =>
|
|
regex.test(part) ? (
|
|
<Typography
|
|
key={index}
|
|
component="span"
|
|
sx={{ backgroundColor: 'yellow', fontWeight: 'bold' }}
|
|
>
|
|
{part}
|
|
</Typography>
|
|
) : (
|
|
part
|
|
)
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box>
|
|
|
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
{/* Header */}
|
|
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
|
<Typography variant="h3" component="h1" gutterBottom>
|
|
<Search sx={{ fontSize: 40, mr: 2, verticalAlign: 'middle' }} />
|
|
{t('title')}
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary">
|
|
{t('subtitle')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Grid container spacing={4}>
|
|
{/* Search Sidebar */}
|
|
<Grid item xs={12} md={3}>
|
|
{/* Search Filters */}
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
<Typography variant="h6">
|
|
<FilterList sx={{ mr: 1, verticalAlign: 'middle' }} />
|
|
{t('filters.title')}
|
|
</Typography>
|
|
<Button size="small" onClick={clearFilters}>
|
|
{t('filters.clear')}
|
|
</Button>
|
|
</Box>
|
|
|
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
<InputLabel>{t('filters.testament')}</InputLabel>
|
|
<Select
|
|
value={filters.testament}
|
|
label={t('filters.testament')}
|
|
onChange={(e) => setFilters({ ...filters, testament: e.target.value as any })}
|
|
>
|
|
<MenuItem value="all">{t('filters.options.all')}</MenuItem>
|
|
<MenuItem value="old">{t('filters.options.old')}</MenuItem>
|
|
<MenuItem value="new">{t('filters.options.new')}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
<InputLabel>{t('filters.version')}</InputLabel>
|
|
<Select
|
|
value={selectedVersion}
|
|
label={t('filters.version')}
|
|
onChange={(e) => setSelectedVersion(e.target.value as string)}
|
|
>
|
|
{versions.map(v => (
|
|
<MenuItem key={v.abbreviation} value={v.abbreviation}>
|
|
{v.name} ({v.abbreviation})
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Accordion>
|
|
<AccordionSummary expandIcon={<ExpandMore />}>
|
|
<Typography variant="body2">{t('filters.specificBooks')}</Typography>
|
|
</AccordionSummary>
|
|
<AccordionDetails>
|
|
<Box sx={{ maxHeight: 200, overflow: 'auto' }}>
|
|
{(filters.testament === 'old' || filters.testament === 'all' ? oldTestamentBooks : [])
|
|
.concat(filters.testament === 'new' || filters.testament === 'all' ? newTestamentBooks : [])
|
|
.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(b => b !== book.bookKey)
|
|
: [...filters.bookKeys, book.bookKey]
|
|
setFilters({ ...filters, bookKeys: newBookKeys })
|
|
}}
|
|
sx={{ mb: 0.5, mr: 0.5 }}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Search History */}
|
|
{searchHistory.length > 0 && (
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
<History sx={{ mr: 1, verticalAlign: 'middle' }} />
|
|
{t('history.title')}
|
|
</Typography>
|
|
{searchHistory.slice(0, 5).map((query, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={query}
|
|
size="small"
|
|
variant="outlined"
|
|
onClick={() => setSearchQuery(query)}
|
|
sx={{ mb: 0.5, mr: 0.5 }}
|
|
/>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Popular Searches */}
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
{t('popular.title')}
|
|
</Typography>
|
|
{popularSearches.map((query, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={query}
|
|
size="small"
|
|
variant="outlined"
|
|
onClick={() => setSearchQuery(query)}
|
|
sx={{ mb: 0.5, mr: 0.5 }}
|
|
/>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
|
|
{/* Main Search Area */}
|
|
<Grid item xs={12} md={9}>
|
|
{/* Search Input */}
|
|
<Card sx={{ mb: 3 }}>
|
|
<CardContent>
|
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
<TextField
|
|
fullWidth
|
|
variant="outlined"
|
|
placeholder={t('input.placeholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<Search />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: searchQuery && (
|
|
<InputAdornment position="end">
|
|
<Button
|
|
size="small"
|
|
onClick={() => setSearchQuery('')}
|
|
>
|
|
<Close />
|
|
</Button>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSearch}
|
|
disabled={!searchQuery.trim() || loading}
|
|
sx={{ minWidth: 100 }}
|
|
>
|
|
{loading ? <CircularProgress size={20} color="inherit" /> : t('button.search')}
|
|
</Button>
|
|
</Box>
|
|
|
|
{filters.bookKeys.length > 0 && (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
{t('searchIn', { books: filters.bookKeys.map(k => booksData.find(b => b.bookKey === k)?.name || k).join(', ') })}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Search Results */}
|
|
{loading && searchQuery && (
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
{t('searching')}
|
|
</Typography>
|
|
<List>
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<ListItem key={index} divider>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
|
<Skeleton variant="text" width="40%" height={28} />
|
|
<Skeleton variant="rounded" width={100} height={24} />
|
|
</Box>
|
|
}
|
|
secondary={
|
|
<Box>
|
|
<Skeleton variant="text" width="100%" height={24} />
|
|
<Skeleton variant="text" width="90%" height={24} />
|
|
<Skeleton variant="text" width="95%" height={24} />
|
|
</Box>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{results.length > 0 && !loading && (
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" gutterBottom>
|
|
{t('results', { count: results.length })}
|
|
</Typography>
|
|
|
|
<List>
|
|
{results.map((result) => (
|
|
<ListItem key={result.id} divider>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
|
<Typography variant="subtitle1" color="primary">
|
|
{result.book} {result.chapter}:{result.verse}
|
|
</Typography>
|
|
<Chip
|
|
label={`${Math.round(result.relevance * 100)}% ${t('relevance')}`}
|
|
size="small"
|
|
color="primary"
|
|
variant="outlined"
|
|
/>
|
|
</Box>
|
|
}
|
|
secondary={
|
|
<Typography variant="body1" sx={{ lineHeight: 1.6, mt: 1 }}>
|
|
{highlightSearchTerm(result.text, searchQuery)}
|
|
</Typography>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!loading && searchQuery && results.length === 0 && (
|
|
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
|
{t('noResults.title')}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('noResults.description')}
|
|
</Typography>
|
|
</Paper>
|
|
)}
|
|
|
|
{!searchQuery && !loading && (
|
|
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
|
<MenuBook sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
|
{t('empty.title')}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('empty.description')}
|
|
</Typography>
|
|
</Paper>
|
|
)}
|
|
</Grid>
|
|
</Grid>
|
|
</Container>
|
|
</Box>
|
|
)
|
|
}
|