Fix Next.js 15 Suspense boundary errors and remove Docker files
- Add Suspense boundaries for useSearchParams in bible and search pages - Extract search page content to separate component for proper client-side rendering - Remove all Docker configuration files and temp documentation - Build now passes successfully without TypeScript or Suspense errors 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
|
import { Suspense } from 'react'
|
||||||
import BibleReader from './reader'
|
import BibleReader from './reader'
|
||||||
|
|
||||||
export default function BiblePage() {
|
export default function BiblePage() {
|
||||||
return <BibleReader />
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<BibleReader />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -1,988 +1,10 @@
|
|||||||
'use client'
|
import { Suspense } from 'react'
|
||||||
|
import SearchContent from './search-content'
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
|
||||||
import { useAuth } from '@/hooks/use-auth'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Container,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
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
|
|
||||||
bookKey?: 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() {
|
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 searchParams = useSearchParams()
|
|
||||||
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)
|
|
||||||
const [shouldSearchFromUrl, setShouldSearchFromUrl] = useState(false)
|
|
||||||
|
|
||||||
// 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 search parameters from URL (run only once on mount)
|
|
||||||
useEffect(() => {
|
|
||||||
const urlQuery = searchParams.get('q')
|
|
||||||
const urlTestament = searchParams.get('testament')
|
|
||||||
const urlSearchType = searchParams.get('type')
|
|
||||||
const urlBooks = searchParams.get('books')
|
|
||||||
|
|
||||||
if (urlQuery) {
|
|
||||||
setSearchQuery(urlQuery)
|
|
||||||
setShouldSearchFromUrl(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urlTestament || urlSearchType || urlBooks) {
|
|
||||||
setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
...(urlTestament && { testament: urlTestament as any }),
|
|
||||||
...(urlSearchType && { searchType: urlSearchType as any }),
|
|
||||||
...(urlBooks && { bookKeys: urlBooks.split(',').filter(Boolean) })
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}, []) // Empty dependency array to run only once
|
|
||||||
|
|
||||||
// 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: Array<{ id: string; name: string; abbreviation: string; isDefault: boolean }> = 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)
|
|
||||||
|
|
||||||
// Update URL with search parameters for sharing and tracking
|
|
||||||
const urlParams = new URLSearchParams({
|
|
||||||
q: searchTerm,
|
|
||||||
...(filters.testament !== 'all' && { testament: filters.testament }),
|
|
||||||
...(filters.searchType !== 'words' && { type: filters.searchType }),
|
|
||||||
...(filters.bookKeys.length > 0 && { books: filters.bookKeys.join(',') })
|
|
||||||
})
|
|
||||||
|
|
||||||
const newUrl = `${window.location.pathname}?${urlParams.toString()}`
|
|
||||||
window.history.replaceState({}, '', newUrl)
|
|
||||||
|
|
||||||
// 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])
|
|
||||||
|
|
||||||
// Trigger search from URL after handleSearch is defined
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldSearchFromUrl && searchQuery) {
|
|
||||||
handleSearch(searchQuery, 1)
|
|
||||||
setShouldSearchFromUrl(false)
|
|
||||||
}
|
|
||||||
}, [shouldSearchFromUrl, searchQuery, handleSearch])
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const bookIdentifier = result.bookId || result.bookKey || result.book
|
|
||||||
router.push(`/${locale}/bible?book=${bookIdentifier}&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 (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<Box sx={{ px: { xs: 2, md: 4 }, py: { xs: 2, md: 4 } }}>
|
<SearchContent />
|
||||||
{/* Header */}
|
</Suspense>
|
||||||
<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} disablePadding>
|
|
||||||
<ListItemButton
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</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 }}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
{/* First row of filters */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
|
||||||
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 auto' }, minWidth: { md: 200 } }}>
|
|
||||||
<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>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 auto' }, minWidth: { md: 200 } }}>
|
|
||||||
<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>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 auto' }, display: 'flex', alignItems: 'center' }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={filters.showContext}
|
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, showContext: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Show context"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Popular searches */}
|
|
||||||
<Box>
|
|
||||||
<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>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Book selection */}
|
|
||||||
<Box>
|
|
||||||
<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>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 4, flexDirection: { xs: 'column', md: 'row' } }}>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<Box sx={{ width: { xs: '100%', md: '25%' }, flexShrink: 0 }}>
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Main Results */}
|
|
||||||
<Box sx={{ flex: 1, width: { xs: '100%', md: '75%' } }}>
|
|
||||||
<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>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
988
app/[locale]/search/search-content.tsx
Normal file
988
app/[locale]/search/search-content.tsx
Normal file
@@ -0,0 +1,988 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
|
import { useAuth } from '@/hooks/use-auth'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
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
|
||||||
|
bookKey?: 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 SearchContent() {
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
const t = useTranslations('pages.search')
|
||||||
|
const locale = useLocale()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
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)
|
||||||
|
const [shouldSearchFromUrl, setShouldSearchFromUrl] = useState(false)
|
||||||
|
|
||||||
|
// 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 search parameters from URL (run only once on mount)
|
||||||
|
useEffect(() => {
|
||||||
|
const urlQuery = searchParams.get('q')
|
||||||
|
const urlTestament = searchParams.get('testament')
|
||||||
|
const urlSearchType = searchParams.get('type')
|
||||||
|
const urlBooks = searchParams.get('books')
|
||||||
|
|
||||||
|
if (urlQuery) {
|
||||||
|
setSearchQuery(urlQuery)
|
||||||
|
setShouldSearchFromUrl(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlTestament || urlSearchType || urlBooks) {
|
||||||
|
setFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
...(urlTestament && { testament: urlTestament as any }),
|
||||||
|
...(urlSearchType && { searchType: urlSearchType as any }),
|
||||||
|
...(urlBooks && { bookKeys: urlBooks.split(',').filter(Boolean) })
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, []) // Empty dependency array to run only once
|
||||||
|
|
||||||
|
// 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: Array<{ id: string; name: string; abbreviation: string; isDefault: boolean }> = 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)
|
||||||
|
|
||||||
|
// Update URL with search parameters for sharing and tracking
|
||||||
|
const urlParams = new URLSearchParams({
|
||||||
|
q: searchTerm,
|
||||||
|
...(filters.testament !== 'all' && { testament: filters.testament }),
|
||||||
|
...(filters.searchType !== 'words' && { type: filters.searchType }),
|
||||||
|
...(filters.bookKeys.length > 0 && { books: filters.bookKeys.join(',') })
|
||||||
|
})
|
||||||
|
|
||||||
|
const newUrl = `${window.location.pathname}?${urlParams.toString()}`
|
||||||
|
window.history.replaceState({}, '', newUrl)
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
|
||||||
|
// Trigger search from URL after handleSearch is defined
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldSearchFromUrl && searchQuery) {
|
||||||
|
handleSearch(searchQuery, 1)
|
||||||
|
setShouldSearchFromUrl(false)
|
||||||
|
}
|
||||||
|
}, [shouldSearchFromUrl, searchQuery, handleSearch])
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const bookIdentifier = result.bookId || result.bookKey || result.book
|
||||||
|
router.push(`/${locale}/bible?book=${bookIdentifier}&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} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</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 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* First row of filters */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 auto' }, minWidth: { md: 200 } }}>
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 auto' }, minWidth: { md: 200 } }}>
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 auto' }, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.showContext}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, showContext: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Show context"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Popular searches */}
|
||||||
|
<Box>
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Book selection */}
|
||||||
|
<Box>
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 4, flexDirection: { xs: 'column', md: 'row' } }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Box sx={{ width: { xs: '100%', md: '25%' }, flexShrink: 0 }}>
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main Results */}
|
||||||
|
<Box sx={{ flex: 1, width: { xs: '100%', md: '75%' } }}>
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: bible_chat
|
|
||||||
POSTGRES_USER: bible_admin
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
|
|
||||||
networks:
|
|
||||||
- bible_network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U bible_admin -d bible_chat"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile.prod
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://bible_admin:${DB_PASSWORD}@postgres:5432/bible_chat
|
|
||||||
AZURE_OPENAI_KEY: ${AZURE_OPENAI_KEY}
|
|
||||||
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
|
|
||||||
AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT}
|
|
||||||
OLLAMA_API_URL: ${OLLAMA_API_URL}
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
|
||||||
NODE_ENV: production
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- bible_network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
|
||||||
- ./certbot/www:/var/www/certbot
|
|
||||||
depends_on:
|
|
||||||
app:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- bible_network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bible_network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: bible_chat
|
|
||||||
POSTGRES_USER: bible_admin
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
networks:
|
|
||||||
- bible_network
|
|
||||||
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile.dev
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- /app/node_modules
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
- "3001:3001" # WebSocket port
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://bible_admin:${DB_PASSWORD:-password}@postgres:5432/bible_chat
|
|
||||||
AZURE_OPENAI_KEY: ${AZURE_OPENAI_KEY}
|
|
||||||
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
|
|
||||||
AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT}
|
|
||||||
OLLAMA_API_URL: ${OLLAMA_API_URL}
|
|
||||||
JWT_SECRET: ${JWT_SECRET:-default-jwt-secret-change-in-production}
|
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-default-nextauth-secret-change-in-production}
|
|
||||||
networks:
|
|
||||||
- bible_network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bible_network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Expose ports
|
|
||||||
EXPOSE 3000 3001
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
CMD ["npm", "run", "dev"]
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Generate Prisma client
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV production
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S nodejs
|
|
||||||
RUN adduser -S nextjs -u 1001
|
|
||||||
|
|
||||||
# Copy built application
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
COPY --from=builder /app/prisma ./prisma
|
|
||||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
|
||||||
|
|
||||||
# Copy package.json for npm scripts
|
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV PORT 3000
|
|
||||||
ENV HOSTNAME "0.0.0.0"
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
upstream app {
|
|
||||||
server app:3000;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
client_max_body_size 10M;
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
add_header X-Content-Type-Options nosniff;
|
|
||||||
add_header X-Frame-Options DENY;
|
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://app;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Timeouts
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
location /health {
|
|
||||||
access_log off;
|
|
||||||
return 200 "healthy\n";
|
|
||||||
add_header Content-Type text/plain;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Static files caching
|
|
||||||
location /_next/static {
|
|
||||||
proxy_pass http://app;
|
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Favicon
|
|
||||||
location /favicon.ico {
|
|
||||||
proxy_pass http://app;
|
|
||||||
add_header Cache-Control "public, max-age=86400";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# BiblicalGuide Admin Dashboard - MVP Features
|
|
||||||
|
|
||||||
## 1. Dashboard Overview (Home)
|
|
||||||
|
|
||||||
### Key Metrics Cards
|
|
||||||
- **Total Users** (number + % change from last week)
|
|
||||||
- **Daily Active Users** (DAU - real-time count)
|
|
||||||
- **AI Conversations Today** (count + cost estimate)
|
|
||||||
- **Active Now** (users currently online)
|
|
||||||
|
|
||||||
### Quick Stats
|
|
||||||
- **New signups** (last 24 hours)
|
|
||||||
- **Total bookmarks created** (all-time)
|
|
||||||
- **Prayer requests** (last 24 hours)
|
|
||||||
- **Server status** (API health, response time)
|
|
||||||
|
|
||||||
### Activity Feed (Live)
|
|
||||||
- New user registrations
|
|
||||||
- Prayer wall posts
|
|
||||||
- High-engagement AI conversations
|
|
||||||
- Error alerts
|
|
||||||
- System notifications
|
|
||||||
|
|
||||||
## 2. User Management
|
|
||||||
|
|
||||||
### User List View
|
|
||||||
```
|
|
||||||
Search: [_________] Filter: [All Users ▼]
|
|
||||||
|
|
||||||
| Email | Name | Joined | Last Active | AI Chats | Status | Actions |
|
|
||||||
|-------|------|--------|-------------|----------|--------|---------|
|
|
||||||
| user@example.com | John D. | 2024-01-15 | 2 hrs ago | 45 | Active | [View] [Ban] |
|
|
||||||
```
|
|
||||||
|
|
||||||
### User Actions
|
|
||||||
- **View Profile** (see detailed user activity)
|
|
||||||
- **Send Email** (direct communication)
|
|
||||||
- **Reset Password** (force password reset)
|
|
||||||
- **Suspend/Ban** (temporary or permanent)
|
|
||||||
- **Delete Account** (GDPR compliance)
|
|
||||||
|
|
||||||
### User Details Modal
|
|
||||||
- Registration date and method
|
|
||||||
- Total AI conversations
|
|
||||||
- Bookmarks count
|
|
||||||
- Last 10 chat topics
|
|
||||||
- Prayer requests posted
|
|
||||||
- Account status history
|
|
||||||
|
|
||||||
## 3. AI Chat Monitoring
|
|
||||||
|
|
||||||
### Conversations Overview
|
|
||||||
- **Total conversations** (today/week/month)
|
|
||||||
- **Average response time** (target: <5 seconds)
|
|
||||||
- **Error rate** (failed responses %)
|
|
||||||
- **Cost tracking** (OpenAI API spend)
|
|
||||||
|
|
||||||
### Live Chat Monitor
|
|
||||||
```
|
|
||||||
Recent Conversations (Auto-refresh every 30s)
|
|
||||||
|
|
||||||
User: "What does the Bible say about forgiveness?"
|
|
||||||
AI: "The Bible speaks extensively about forgiveness..."
|
|
||||||
[View Full] [Flag] [Quality: ⭐⭐⭐⭐⭐]
|
|
||||||
|
|
||||||
User: "Help me understand Romans 8"
|
|
||||||
AI: "Romans 8 is one of the most powerful chapters..."
|
|
||||||
[View Full] [Flag] [Quality: ⭐⭐⭐⭐⭐]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quality Control
|
|
||||||
- **Flag inappropriate requests**
|
|
||||||
- **Review AI responses** for theological accuracy
|
|
||||||
- **Common questions** (FAQ generation)
|
|
||||||
- **Failed responses** log
|
|
||||||
- **Response ratings** (if users rate them)
|
|
||||||
|
|
||||||
### Cost Management
|
|
||||||
- Daily API usage ($X.XX)
|
|
||||||
- Projection for current month
|
|
||||||
- Per-user average cost
|
|
||||||
- High-usage user alerts
|
|
||||||
|
|
||||||
## 4. Content Moderation
|
|
||||||
|
|
||||||
### Prayer Wall Moderation
|
|
||||||
```
|
|
||||||
Pending Review (3)
|
|
||||||
|
|
||||||
"Please pray for my family..."
|
|
||||||
[Approve] [Edit] [Reject] [Ban User]
|
|
||||||
|
|
||||||
"Spam content here..."
|
|
||||||
[Approve] [Edit] [Reject] [Ban User]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Moderation Queue
|
|
||||||
- **Prayer requests** (pending approval)
|
|
||||||
- **Reported content** (user flags)
|
|
||||||
- **AI conversation flags** (inappropriate)
|
|
||||||
- **Bulk actions** (approve/reject all)
|
|
||||||
|
|
||||||
### Auto-Moderation Settings
|
|
||||||
- Profanity filter (on/off)
|
|
||||||
- Spam detection threshold
|
|
||||||
- Auto-approve trusted users
|
|
||||||
- Keyword blacklist management
|
|
||||||
|
|
||||||
## 5. Analytics Dashboard
|
|
||||||
|
|
||||||
### User Analytics
|
|
||||||
- **Growth Chart** (daily new users - line graph)
|
|
||||||
- **Retention Funnel**
|
|
||||||
- Day 1: 100%
|
|
||||||
- Day 7: 45%
|
|
||||||
- Day 30: 30%
|
|
||||||
- **User Segments**
|
|
||||||
- Power users (>10 chats/week)
|
|
||||||
- Regular (3-10 chats/week)
|
|
||||||
- Casual (<3 chats/week)
|
|
||||||
- Dormant (no activity 7+ days)
|
|
||||||
|
|
||||||
### Engagement Metrics
|
|
||||||
- **Most Read Bible Books** (top 10 bar chart)
|
|
||||||
- **Popular Chat Topics** (word cloud)
|
|
||||||
- **Peak Usage Hours** (heatmap)
|
|
||||||
- **Feature Usage** (bookmarks vs chat vs prayer wall)
|
|
||||||
|
|
||||||
### Simple Conversion Tracking
|
|
||||||
- Sign-up to first chat
|
|
||||||
- First chat to bookmark
|
|
||||||
- Single to returning user
|
|
||||||
- Free to premium (when implemented)
|
|
||||||
|
|
||||||
## 6. System Administration
|
|
||||||
|
|
||||||
### Quick Actions
|
|
||||||
- **Broadcast Message** (banner to all users)
|
|
||||||
- **Maintenance Mode** (on/off toggle)
|
|
||||||
- **Clear Cache** (Redis flush)
|
|
||||||
- **Backup Database** (manual trigger)
|
|
||||||
|
|
||||||
### API Management
|
|
||||||
- **OpenAI API Status**
|
|
||||||
- Current balance
|
|
||||||
- Rate limit status
|
|
||||||
- Error rate
|
|
||||||
- Switch API keys
|
|
||||||
- **Bible API Status**
|
|
||||||
- Response time
|
|
||||||
- Cache hit rate
|
|
||||||
- Error logs
|
|
||||||
|
|
||||||
### Error Monitoring
|
|
||||||
```
|
|
||||||
Recent Errors (Last 24 hours)
|
|
||||||
|
|
||||||
Type | Count | Last Occurred | Status
|
|
||||||
-----|-------|---------------|--------
|
|
||||||
API Timeout | 12 | 10 min ago | [View] [Resolve]
|
|
||||||
Login Failed | 45 | 2 min ago | [View] [Ignore]
|
|
||||||
DB Connection | 0 | - | OK
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Health
|
|
||||||
- CPU usage (%)
|
|
||||||
- Memory usage (%)
|
|
||||||
- Database connections
|
|
||||||
- API response times
|
|
||||||
- Disk space remaining
|
|
||||||
|
|
||||||
## 7. Communications
|
|
||||||
|
|
||||||
### Email Templates (Basic)
|
|
||||||
- Welcome email
|
|
||||||
- Password reset
|
|
||||||
- Daily verse (if enabled)
|
|
||||||
- Account suspended
|
|
||||||
- System announcements
|
|
||||||
|
|
||||||
### Bulk Email
|
|
||||||
- **Send to**: All users / Active last 7 days / Segment
|
|
||||||
- **Subject**: [_________]
|
|
||||||
- **Message**: [Rich text editor]
|
|
||||||
- **Schedule**: Now / Later
|
|
||||||
- [Send Test] [Schedule] [Send Now]
|
|
||||||
|
|
||||||
### In-App Notifications
|
|
||||||
- Create announcement banner
|
|
||||||
- Target specific user segments
|
|
||||||
- Set expiration time
|
|
||||||
- Track dismissal rate
|
|
||||||
|
|
||||||
## 8. Settings & Configuration
|
|
||||||
|
|
||||||
### App Settings
|
|
||||||
- **Site name**: BiblicalGuide
|
|
||||||
- **Support email**: support@biblical-guide.com
|
|
||||||
- **Daily verse**: Enabled/Disabled
|
|
||||||
- **Prayer wall**: Public/Moderated/Disabled
|
|
||||||
- **AI model**: GPT-4/GPT-3.5
|
|
||||||
- **Rate limits**: X chats per user per day
|
|
||||||
|
|
||||||
### Feature Toggles
|
|
||||||
- [ ] AI Chat enabled
|
|
||||||
- [ ] Prayer Wall enabled
|
|
||||||
- [ ] User registration open
|
|
||||||
- [ ] Daily verse emails
|
|
||||||
- [ ] Social sharing
|
|
||||||
- [ ] Maintenance mode
|
|
||||||
|
|
||||||
### Admin Users
|
|
||||||
```
|
|
||||||
Admin Users (3)
|
|
||||||
|
|
||||||
Email | Role | Last Login | Actions
|
|
||||||
------|------|------------|--------
|
|
||||||
admin@site.com | Super Admin | 1 hr ago | [Edit] [Remove]
|
|
||||||
support@site.com | Moderator | 2 days ago | [Edit] [Remove]
|
|
||||||
[+ Add Admin]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. Simple Reporting
|
|
||||||
|
|
||||||
### Daily Report Email
|
|
||||||
- New users count
|
|
||||||
- Total AI conversations
|
|
||||||
- API costs
|
|
||||||
- Error summary
|
|
||||||
- Top chat topics
|
|
||||||
- System health status
|
|
||||||
|
|
||||||
### Export Data
|
|
||||||
- **User list** (CSV)
|
|
||||||
- **Chat logs** (last 30 days)
|
|
||||||
- **Prayer requests** (CSV)
|
|
||||||
- **Analytics summary** (PDF)
|
|
||||||
|
|
||||||
## 10. Security Section
|
|
||||||
|
|
||||||
### Security Log
|
|
||||||
```
|
|
||||||
Recent Security Events
|
|
||||||
|
|
||||||
Time | User | Event | IP Address | Action
|
|
||||||
-----|------|-------|------------|--------
|
|
||||||
10:23 AM | john@... | Failed login (3x) | 192.168.1.1 | [Block IP]
|
|
||||||
09:45 AM | Admin login | Success | 10.0.0.1 | -
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Actions
|
|
||||||
- View failed login attempts
|
|
||||||
- IP blocking/allowlisting
|
|
||||||
- Force logout all users
|
|
||||||
- Require password reset (bulk)
|
|
||||||
- 2FA enforcement settings
|
|
||||||
|
|
||||||
## Implementation Priority
|
|
||||||
|
|
||||||
### Phase 1 - Critical (Week 1)
|
|
||||||
1. **Login/Authentication** for admins
|
|
||||||
2. **Dashboard overview** (basic metrics)
|
|
||||||
3. **User list** with basic actions
|
|
||||||
4. **AI conversation monitoring** (view only)
|
|
||||||
|
|
||||||
### Phase 2 - Essential (Week 2)
|
|
||||||
1. **Prayer wall moderation**
|
|
||||||
2. **User management** (suspend/ban)
|
|
||||||
3. **Basic analytics** (users, engagement)
|
|
||||||
4. **Error monitoring**
|
|
||||||
|
|
||||||
### Phase 3 - Important (Week 3)
|
|
||||||
1. **Email communications**
|
|
||||||
2. **System settings**
|
|
||||||
3. **Export functionality**
|
|
||||||
4. **Cost tracking**
|
|
||||||
|
|
||||||
## Tech Stack for Admin
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **React** with **Recharts** for graphs
|
|
||||||
- **Tailwind CSS** with **shadcn/ui** components
|
|
||||||
- **React Table** for data tables
|
|
||||||
- **React Query** for data fetching
|
|
||||||
|
|
||||||
### Backend Additions
|
|
||||||
- **Admin authentication** (separate from users)
|
|
||||||
- **Role-based access** (admin, moderator)
|
|
||||||
- **Audit logging** (track all admin actions)
|
|
||||||
- **Scheduled jobs** (daily reports)
|
|
||||||
|
|
||||||
### Database Schema Additions
|
|
||||||
```sql
|
|
||||||
-- Admin users table
|
|
||||||
admin_users (
|
|
||||||
id, email, password_hash, role,
|
|
||||||
last_login, created_at
|
|
||||||
)
|
|
||||||
|
|
||||||
-- Audit log table
|
|
||||||
audit_logs (
|
|
||||||
id, admin_id, action, target_user_id,
|
|
||||||
details, ip_address, timestamp
|
|
||||||
)
|
|
||||||
|
|
||||||
-- System settings table
|
|
||||||
settings (
|
|
||||||
key, value, updated_at, updated_by
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access Control Levels
|
|
||||||
|
|
||||||
### Super Admin
|
|
||||||
- Full access to everything
|
|
||||||
- Can manage other admins
|
|
||||||
- System configuration
|
|
||||||
- Database operations
|
|
||||||
|
|
||||||
### Moderator
|
|
||||||
- Content moderation
|
|
||||||
- User management (not deletion)
|
|
||||||
- View analytics
|
|
||||||
- Cannot change system settings
|
|
||||||
|
|
||||||
### Support
|
|
||||||
- View user details
|
|
||||||
- Reset passwords
|
|
||||||
- View chat logs
|
|
||||||
- Cannot ban/delete users
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
1. **Separate admin authentication** (not regular user accounts)
|
|
||||||
2. **IP allowlisting** for admin access
|
|
||||||
3. **Audit log** all admin actions
|
|
||||||
4. **Session timeout** after 30 min inactive
|
|
||||||
5. **2FA required** for all admin accounts
|
|
||||||
6. **Read-only mode** for most views (explicit edit mode)
|
|
||||||
|
|
||||||
## Success Metrics for Admin
|
|
||||||
|
|
||||||
- **Response time** to user issues < 1 hour
|
|
||||||
- **Moderation queue** cleared daily
|
|
||||||
- **System uptime** > 99.9%
|
|
||||||
- **API costs** within budget
|
|
||||||
- **User complaints** resolved < 24 hours
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
# AI Chat Improvements Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Enhance the AI chat system with persistent chat history and conversation memory to provide a more engaging and contextual user experience.
|
|
||||||
|
|
||||||
## Current State Analysis
|
|
||||||
- ✅ Basic AI chat with Azure OpenAI integration
|
|
||||||
- ✅ ReactMarkdown rendering for formatted responses
|
|
||||||
- ✅ Floating chat component with fullscreen mode
|
|
||||||
- ✅ Language-specific responses (Romanian/English)
|
|
||||||
- ❌ No chat persistence between sessions
|
|
||||||
- ❌ Limited conversation memory (only last 3 messages)
|
|
||||||
- ❌ No user-specific chat history
|
|
||||||
- ❌ No chat management UI
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
1. **Persistent Chat History**: Store chat conversations in database per user and language
|
|
||||||
2. **Enhanced Memory**: Maintain longer conversation context for better AI responses
|
|
||||||
3. **Chat Management**: Allow users to view, organize, and manage their chat history
|
|
||||||
4. **Multi-language Support**: Separate chat histories for Romanian and English
|
|
||||||
|
|
||||||
## Technical Implementation Plan
|
|
||||||
|
|
||||||
### 1. Database Schema Extensions
|
|
||||||
|
|
||||||
#### 1.1 Chat Conversations Table
|
|
||||||
```prisma
|
|
||||||
model ChatConversation {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String? // Optional for anonymous users
|
|
||||||
title String // Auto-generated from first message
|
|
||||||
language String // 'ro' or 'en'
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
lastMessageAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
messages ChatMessage[]
|
|
||||||
|
|
||||||
@@index([userId, language, lastMessageAt])
|
|
||||||
@@index([isActive, lastMessageAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ChatMessage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
conversationId String
|
|
||||||
role ChatMessageRole
|
|
||||||
content String @db.Text
|
|
||||||
timestamp DateTime @default(now())
|
|
||||||
metadata Json? // For storing additional context
|
|
||||||
|
|
||||||
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([conversationId, timestamp])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ChatMessageRole {
|
|
||||||
USER
|
|
||||||
ASSISTANT
|
|
||||||
SYSTEM
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 Update User Model
|
|
||||||
```prisma
|
|
||||||
model User {
|
|
||||||
// ... existing fields
|
|
||||||
chatConversations ChatConversation[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. API Endpoints
|
|
||||||
|
|
||||||
#### 2.1 Chat Conversations API (`/api/chat/conversations`)
|
|
||||||
- **GET**: List user's conversations (paginated, filtered by language)
|
|
||||||
- **POST**: Create new conversation
|
|
||||||
- **DELETE /:id**: Delete conversation
|
|
||||||
|
|
||||||
#### 2.2 Enhanced Chat API (`/api/chat`)
|
|
||||||
- **Modify existing POST**: Include conversation management
|
|
||||||
- **GET /:conversationId**: Get conversation history
|
|
||||||
- **PUT /:conversationId**: Update conversation (rename, etc.)
|
|
||||||
|
|
||||||
#### 2.3 Chat Messages API (`/api/chat/:conversationId/messages`)
|
|
||||||
- **GET**: Get all messages in conversation (paginated)
|
|
||||||
- **POST**: Add message to conversation
|
|
||||||
|
|
||||||
### 3. Frontend Components
|
|
||||||
|
|
||||||
#### 3.1 Enhanced Floating Chat Component
|
|
||||||
```typescript
|
|
||||||
interface FloatingChatProps {
|
|
||||||
conversationId?: string
|
|
||||||
initialMessage?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// New state management
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([])
|
|
||||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null)
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2 Chat History Sidebar
|
|
||||||
```typescript
|
|
||||||
interface ChatHistorySidebarProps {
|
|
||||||
conversations: Conversation[]
|
|
||||||
activeConversationId: string | null
|
|
||||||
onSelectConversation: (id: string) => void
|
|
||||||
onNewConversation: () => void
|
|
||||||
onDeleteConversation: (id: string) => void
|
|
||||||
language: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3 Conversation List Item
|
|
||||||
```typescript
|
|
||||||
interface ConversationListItemProps {
|
|
||||||
conversation: Conversation
|
|
||||||
isActive: boolean
|
|
||||||
onClick: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
onRename: (newTitle: string) => void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Implementation Phases
|
|
||||||
|
|
||||||
#### Phase 1: Database Schema & Basic API
|
|
||||||
1. **Create Prisma migrations** for new tables
|
|
||||||
2. **Implement conversation CRUD APIs**
|
|
||||||
3. **Add database seed scripts** for testing
|
|
||||||
4. **Update existing chat API** to work with conversations
|
|
||||||
|
|
||||||
#### Phase 2: Enhanced Memory System
|
|
||||||
1. **Modify chat API** to include more conversation context
|
|
||||||
2. **Implement intelligent context selection** (last 10-15 messages)
|
|
||||||
3. **Add conversation summarization** for very long chats
|
|
||||||
4. **Optimize token usage** with smart context management
|
|
||||||
|
|
||||||
#### Phase 3: Frontend Chat Management
|
|
||||||
1. **Add conversation state management** to floating chat
|
|
||||||
2. **Implement chat history sidebar**
|
|
||||||
3. **Add conversation creation/deletion** functionality
|
|
||||||
4. **Implement conversation switching** within chat
|
|
||||||
|
|
||||||
#### Phase 4: Advanced Features
|
|
||||||
1. **Auto-generate conversation titles** from first message
|
|
||||||
2. **Add conversation search/filtering**
|
|
||||||
3. **Implement conversation sharing** (optional)
|
|
||||||
4. **Add conversation export** functionality
|
|
||||||
|
|
||||||
### 5. Detailed Implementation Steps
|
|
||||||
|
|
||||||
#### 5.1 Database Setup
|
|
||||||
```bash
|
|
||||||
# Create migration
|
|
||||||
npx prisma migrate dev --name add-chat-conversations
|
|
||||||
|
|
||||||
# Update database
|
|
||||||
npx prisma db push
|
|
||||||
|
|
||||||
# Generate client
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5.2 API Implementation Strategy
|
|
||||||
|
|
||||||
**Chat API Enhancement (`/api/chat/route.ts`)**:
|
|
||||||
```typescript
|
|
||||||
// Modified request schema
|
|
||||||
const chatRequestSchema = z.object({
|
|
||||||
message: z.string().min(1),
|
|
||||||
conversationId: z.string().optional(),
|
|
||||||
locale: z.string().default('ro'),
|
|
||||||
userId: z.string().optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Enhanced response
|
|
||||||
interface ChatResponse {
|
|
||||||
success: boolean
|
|
||||||
response: string
|
|
||||||
conversationId: string
|
|
||||||
messageId: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Conversation Management Logic**:
|
|
||||||
```typescript
|
|
||||||
async function handleChatMessage(
|
|
||||||
message: string,
|
|
||||||
conversationId?: string,
|
|
||||||
locale: string = 'ro',
|
|
||||||
userId?: string
|
|
||||||
) {
|
|
||||||
// 1. Get or create conversation
|
|
||||||
const conversation = conversationId
|
|
||||||
? await getConversation(conversationId)
|
|
||||||
: await createConversation(userId, locale, message)
|
|
||||||
|
|
||||||
// 2. Get conversation history (last 15 messages)
|
|
||||||
const history = await getConversationHistory(conversation.id, 15)
|
|
||||||
|
|
||||||
// 3. Generate AI response with full context
|
|
||||||
const aiResponse = await generateBiblicalResponse(message, locale, history)
|
|
||||||
|
|
||||||
// 4. Save both user message and AI response
|
|
||||||
await saveMessages(conversation.id, [
|
|
||||||
{ role: 'user', content: message },
|
|
||||||
{ role: 'assistant', content: aiResponse }
|
|
||||||
])
|
|
||||||
|
|
||||||
// 5. Update conversation metadata
|
|
||||||
await updateConversationActivity(conversation.id)
|
|
||||||
|
|
||||||
return { response: aiResponse, conversationId: conversation.id }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5.3 Frontend State Management
|
|
||||||
|
|
||||||
**Enhanced Floating Chat Hook**:
|
|
||||||
```typescript
|
|
||||||
function useFloatingChat() {
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([])
|
|
||||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null)
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const loadConversations = useCallback(async () => {
|
|
||||||
// Fetch user's conversations
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const switchConversation = useCallback(async (conversationId: string) => {
|
|
||||||
// Load conversation messages
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const startNewConversation = useCallback(() => {
|
|
||||||
// Reset state for new conversation
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const sendMessage = useCallback(async (message: string) => {
|
|
||||||
// Send message with conversation context
|
|
||||||
}, [activeConversation])
|
|
||||||
|
|
||||||
return {
|
|
||||||
conversations,
|
|
||||||
activeConversation,
|
|
||||||
messages,
|
|
||||||
isLoading,
|
|
||||||
loadConversations,
|
|
||||||
switchConversation,
|
|
||||||
startNewConversation,
|
|
||||||
sendMessage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. UI/UX Enhancements
|
|
||||||
|
|
||||||
#### 6.1 Chat History Sidebar Layout
|
|
||||||
```
|
|
||||||
┌─────────────────┬──────────────────────┐
|
|
||||||
│ Chat History │ Active Chat │
|
|
||||||
│ │ │
|
|
||||||
│ ○ New Chat │ ┌─ Messages ─────┐ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ ╔═ Today ═══ │ │ User: Question │ │
|
|
||||||
│ ║ ○ Bible Q&A │ │ AI: Response │ │
|
|
||||||
│ ║ ○ Prayer Help │ │ ... │ │
|
|
||||||
│ ╚═══════════ │ │ │ │
|
|
||||||
│ │ └────────────────┘ │
|
|
||||||
│ ╔═ Yesterday ═ │ ┌─ Input ──────────┐ │
|
|
||||||
│ ║ ○ Verse Study │ │ [Type message... ]│ │
|
|
||||||
│ ╚═══════════ │ └──────────────────┘ │
|
|
||||||
└─────────────────┴──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.2 Mobile-Responsive Design
|
|
||||||
- **Mobile**: Stack history as overlay/drawer
|
|
||||||
- **Tablet**: Side-by-side with collapsed history
|
|
||||||
- **Desktop**: Full side-by-side layout
|
|
||||||
|
|
||||||
### 7. Performance Considerations
|
|
||||||
|
|
||||||
#### 7.1 Database Optimization
|
|
||||||
- **Indexes**: conversation lookups, message pagination
|
|
||||||
- **Pagination**: Limit conversation and message queries
|
|
||||||
- **Cleanup**: Archive old conversations after 6 months
|
|
||||||
|
|
||||||
#### 7.2 Frontend Optimization
|
|
||||||
- **Lazy loading**: Load conversations on demand
|
|
||||||
- **Virtualization**: For large message lists
|
|
||||||
- **Caching**: Cache conversation metadata
|
|
||||||
|
|
||||||
#### 7.3 AI Context Management
|
|
||||||
- **Smart truncation**: Summarize old messages if context too long
|
|
||||||
- **Relevance scoring**: Prioritize recent and relevant messages
|
|
||||||
- **Token optimization**: Balance context richness vs cost
|
|
||||||
|
|
||||||
### 8. Security & Privacy
|
|
||||||
|
|
||||||
#### 8.1 Data Protection
|
|
||||||
- **User isolation**: Strict user-based access control
|
|
||||||
- **Encryption**: Sensitive conversation content
|
|
||||||
- **Retention policy**: Auto-delete after configurable period
|
|
||||||
|
|
||||||
#### 8.2 Anonymous Users
|
|
||||||
- **Session-based storage**: For non-authenticated users
|
|
||||||
- **Migration path**: Convert anonymous chats to user accounts
|
|
||||||
|
|
||||||
### 9. Testing Strategy
|
|
||||||
|
|
||||||
#### 9.1 Unit Tests
|
|
||||||
- Conversation CRUD operations
|
|
||||||
- Message history management
|
|
||||||
- AI context generation
|
|
||||||
|
|
||||||
#### 9.2 Integration Tests
|
|
||||||
- Full chat flow with persistence
|
|
||||||
- Conversation switching
|
|
||||||
- Multi-language handling
|
|
||||||
|
|
||||||
#### 9.3 User Testing
|
|
||||||
- Chat history navigation
|
|
||||||
- Conversation management
|
|
||||||
- Mobile responsiveness
|
|
||||||
|
|
||||||
### 10. Deployment Considerations
|
|
||||||
|
|
||||||
#### 10.1 Migration Strategy
|
|
||||||
- **Backward compatibility**: Existing chat continues to work
|
|
||||||
- **Data migration**: Convert existing session data if applicable
|
|
||||||
- **Feature flags**: Gradual rollout of new features
|
|
||||||
|
|
||||||
#### 10.2 Monitoring
|
|
||||||
- **Conversation metrics**: Creation, length, engagement
|
|
||||||
- **Performance monitoring**: API response times
|
|
||||||
- **Error tracking**: Failed chat operations
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
1. **User Engagement**: Longer chat sessions, return conversations
|
|
||||||
2. **Conversation Quality**: Better AI responses with context
|
|
||||||
3. **User Satisfaction**: Positive feedback on chat experience
|
|
||||||
4. **Technical Performance**: Fast conversation loading, reliable persistence
|
|
||||||
|
|
||||||
## Timeline Estimate
|
|
||||||
|
|
||||||
- **Phase 1** (Database & API): 3-4 days
|
|
||||||
- **Phase 2** (Enhanced Memory): 2-3 days
|
|
||||||
- **Phase 3** (Frontend Management): 4-5 days
|
|
||||||
- **Phase 4** (Advanced Features): 3-4 days
|
|
||||||
|
|
||||||
**Total**: ~2-3 weeks for complete implementation
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Review and approve this plan
|
|
||||||
2. Begin with Phase 1: Database schema and basic API
|
|
||||||
3. Implement incremental improvements
|
|
||||||
4. Test thoroughly at each phase
|
|
||||||
5. Gather user feedback and iterate
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
# Azure OpenAI **embed-3** → Postgres + pgvector Ingestion Guide (Bible Corpus)
|
|
||||||
|
|
||||||
**Goal**: Create a production‑ready Python script that ingests the full Bible (Markdown source) into **Postgres** with **pgvector** and **full‑text** metadata, using **Azure OpenAI `embed-3`** embeddings. The vectors will power a consumer chat assistant (Q&A & conversations about the Bible) and a backend agent that generates custom prayers.
|
|
||||||
|
|
||||||
> Sample corpus used here: Romanian *Biblia Fidela* (Markdown). Structure contains books, chapters, verses (e.g., *Geneza 1:1…*) and a TOC in the file. fileciteturn0file0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0) Architecture at a glance
|
|
||||||
|
|
||||||
- **Input**: Bible in Markdown (`*.md`) → parser → normalized records: *(book, chapter, verse, text, lang=ro)*
|
|
||||||
- **Embedding**: Azure OpenAI **embed-3** (prefer `text-embedding-3-large`, 3072‑D). Batch inputs to cut cost/latency.
|
|
||||||
- **Storage**: Postgres with:
|
|
||||||
- `pgvector` column `embedding vector(3072)`
|
|
||||||
- `tsvector` column for hybrid lexical search (Romanian or English config as needed)
|
|
||||||
- metadata columns for fast filtering (book, chapter, verse, testament, translation, language)
|
|
||||||
- **Indexes**: `ivfflat` over `embedding`, GIN over `tsv` (and btree over metadata)
|
|
||||||
- **Retrieval**:
|
|
||||||
- Dense vector kNN
|
|
||||||
- Hybrid: combine kNN score + BM25/tsvector
|
|
||||||
- Windowed context stitching (neighbor verses) for chat
|
|
||||||
- **Consumers**:
|
|
||||||
- Chat assistant: answer + cite (book:chapter:verse).
|
|
||||||
- Prayer agent: prompt‑compose with retrieved passages & user intents.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Prerequisites
|
|
||||||
|
|
||||||
### Postgres + pgvector
|
|
||||||
```bash
|
|
||||||
# Install pgvector (on Ubuntu)
|
|
||||||
sudo apt-get update && sudo apt-get install -y postgresql postgresql-contrib
|
|
||||||
# In psql as superuser:
|
|
||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python deps
|
|
||||||
```bash
|
|
||||||
python -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install psycopg[binary] pgvector pydantic python-dotenv httpx tqdm rapidfuzz
|
|
||||||
```
|
|
||||||
|
|
||||||
> `httpx` for HTTP (async‑capable), `pgvector` adapter, `rapidfuzz` for optional de‑dup or heuristic joins, `tqdm` for progress.
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
- Create **Embeddings** deployment for **`text-embedding-3-large`** (or `-small` if cost sensitive). Name it (e.g.) `embeddings`.
|
|
||||||
- Collect:
|
|
||||||
- `AZURE_OPENAI_ENDPOINT=https://<your>.openai.azure.com/`
|
|
||||||
- `AZURE_OPENAI_API_KEY=...`
|
|
||||||
- `AZURE_OPENAI_API_VERSION=2024-05-01-preview` *(or your current stable)*
|
|
||||||
- `AZURE_OPENAI_EMBED_DEPLOYMENT=embeddings` *(your deployment name)*
|
|
||||||
|
|
||||||
Create `.env`:
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost:5432/bible
|
|
||||||
AZURE_OPENAI_ENDPOINT=https://YOUR_RESOURCE.openai.azure.com/
|
|
||||||
AZURE_OPENAI_API_KEY=YOUR_KEY
|
|
||||||
AZURE_OPENAI_API_VERSION=2024-05-01-preview
|
|
||||||
AZURE_OPENAI_EMBED_DEPLOYMENT=embeddings
|
|
||||||
EMBED_DIMS=3072
|
|
||||||
BIBLE_MD_PATH=./Biblia-Fidela-limba-romana.md
|
|
||||||
LANG_CODE=ro
|
|
||||||
TRANSLATION_CODE=FIDELA
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Database schema
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- One-time setup in your database
|
|
||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bible_passages (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
testament TEXT NOT NULL, -- 'OT' or 'NT'
|
|
||||||
book TEXT NOT NULL,
|
|
||||||
chapter INT NOT NULL,
|
|
||||||
verse INT NOT NULL,
|
|
||||||
ref TEXT GENERATED ALWAYS AS (book || ' ' || chapter || ':' || verse) STORED,
|
|
||||||
lang TEXT NOT NULL DEFAULT 'ro',
|
|
||||||
translation TEXT NOT NULL DEFAULT 'FIDELA',
|
|
||||||
text_raw TEXT NOT NULL, -- exact verse text
|
|
||||||
text_norm TEXT NOT NULL, -- normalized/cleaned text (embedding input)
|
|
||||||
tsv tsvector,
|
|
||||||
embedding vector(3072), -- 1536 if using embed-3-small
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Uniqueness by canonical reference within translation/language
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_ref_lang ON bible_passages (translation, lang, book, chapter, verse);
|
|
||||||
|
|
||||||
-- Full-text index (choose config; Romanian available if installed via ISPELL; else use 'simple' or 'english')
|
|
||||||
-- If you have pg_catalog.romanian, use that. Else fallback to 'simple' but keep lexemes.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tsv ON bible_passages USING GIN (tsv);
|
|
||||||
|
|
||||||
-- Vector index (choose nlist to match data size; we set after populating table)
|
|
||||||
-- First create a flat index for small data, or IVFFLAT for scale:
|
|
||||||
-- Requires ANALYZE beforehand and SET enable_seqscan=off for kNN plans.
|
|
||||||
```
|
|
||||||
|
|
||||||
After loading, build the IVFFLAT index (the table must be populated first):
|
|
||||||
```sql
|
|
||||||
-- Example: around 31k verses ⇒ nlist ~ 100–200 is reasonable; tune per EXPLAIN ANALYZE
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_vec_ivfflat
|
|
||||||
ON bible_passages USING ivfflat (embedding vector_cosine_ops)
|
|
||||||
WITH (lists = 200);
|
|
||||||
```
|
|
||||||
|
|
||||||
Trigger to keep `updated_at` fresh:
|
|
||||||
```sql
|
|
||||||
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$
|
|
||||||
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_bible_updated ON bible_passages;
|
|
||||||
CREATE TRIGGER trg_bible_updated BEFORE UPDATE ON bible_passages
|
|
||||||
FOR EACH ROW EXECUTE PROCEDURE touch_updated_at();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Parsing & Chunking strategy (large, high‑quality)
|
|
||||||
|
|
||||||
**Why verse‑level?** It’s the canonical granular unit for Bible QA.
|
|
||||||
**Context‑stitching**: during retrieval, fetch neighbor verses (±N) to maintain narrative continuity.
|
|
||||||
**Normalization** steps (for `text_norm`):
|
|
||||||
- Strip verse numbers and sidenotes if present in raw lines.
|
|
||||||
- Collapse whitespace, unify quotes, remove page headers/footers and TOC artifacts.
|
|
||||||
- Preserve punctuation; avoid stemming before embeddings.
|
|
||||||
- Lowercasing optional (OpenAI embeddings are case-robust).
|
|
||||||
|
|
||||||
**Testament/book detection**: From headings and TOC present in the Markdown; detect Book → Chapter → Verse boundaries via regex.
|
|
||||||
Example regex heuristics (tune to your file):
|
|
||||||
- Book headers: `^(?P<book>[A-ZĂÂÎȘȚ].+?)\s*$` (bounded by known canon order)
|
|
||||||
- Chapter headers: `^Capitolul\s+(?P<ch>\d+)` or `^CApitoLuL\s+(?P<ch>\d+)` (case variations)
|
|
||||||
- Verse lines: `^(?P<verse>\d+)\s+(.+)$`
|
|
||||||
|
|
||||||
> The provided Markdown clearly shows book order (e.g., *Geneza*, *Exodul*, …; NT: *Matei*, *Marcu*, …) and verse lines like “**1** LA început…”. fileciteturn0file0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Python ingestion script
|
|
||||||
|
|
||||||
> **Save as** `ingest_bible_pgvector.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
import os, re, json, math, time, asyncio
|
|
||||||
from typing import List, Dict, Tuple, Iterable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import httpx
|
|
||||||
import psycopg
|
|
||||||
from psycopg.rows import dict_row
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
AZ_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT", "").rstrip("/")
|
|
||||||
AZ_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
|
|
||||||
AZ_API_VER = os.getenv("AZURE_OPENAI_API_VERSION", "2024-05-01-preview")
|
|
||||||
AZ_DEPLOYMENT = os.getenv("AZURE_OPENAI_EMBED_DEPLOYMENT", "embeddings")
|
|
||||||
EMBED_DIMS = int(os.getenv("EMBED_DIMS", "3072"))
|
|
||||||
DB_URL = os.getenv("DATABASE_URL")
|
|
||||||
BIBLE_MD_PATH = os.getenv("BIBLE_MD_PATH")
|
|
||||||
LANG_CODE = os.getenv("LANG_CODE", "ro")
|
|
||||||
TRANSLATION = os.getenv("TRANSLATION_CODE", "FIDELA")
|
|
||||||
|
|
||||||
assert AZ_ENDPOINT and AZ_API_KEY and DB_URL and BIBLE_MD_PATH, "Missing required env vars"
|
|
||||||
|
|
||||||
EMBED_URL = f"{AZ_ENDPOINT}/openai/deployments/{AZ_DEPLOYMENT}/embeddings?api-version={AZ_API_VER}"
|
|
||||||
|
|
||||||
BOOKS_OT = [
|
|
||||||
"Geneza","Exodul","Leviticul","Numeri","Deuteronom","Iosua","Judecători","Rut",
|
|
||||||
"1 Samuel","2 Samuel","1 Imparati","2 Imparati","1 Cronici","2 Cronici","Ezra","Neemia","Estera",
|
|
||||||
"Iov","Psalmii","Proverbe","Eclesiastul","Cântarea Cântărilor","Isaia","Ieremia","Plângerile",
|
|
||||||
"Ezechiel","Daniel","Osea","Ioel","Amos","Obadia","Iona","Mica","Naum","Habacuc","Țefania","Hagai","Zaharia","Maleahi"
|
|
||||||
]
|
|
||||||
BOOKS_NT = [
|
|
||||||
"Matei","Marcu","Luca","Ioan","Faptele Apostolilor","Romani","1 Corinteni","2 Corinteni",
|
|
||||||
"Galateni","Efeseni","Filipeni","Coloseni","1 Tesaloniceni","2 Tesaloniceni","1 Timotei","2 Timotei",
|
|
||||||
"Titus","Filimon","Evrei","Iacov","1 Petru","2 Petru","1 Ioan","2 Ioan","3 Ioan","Iuda","Revelaţia"
|
|
||||||
]
|
|
||||||
|
|
||||||
BOOK_CANON = {b:("OT" if b in BOOKS_OT else "NT") for b in BOOKS_OT + BOOKS_NT}
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Verse:
|
|
||||||
testament: str
|
|
||||||
book: str
|
|
||||||
chapter: int
|
|
||||||
verse: int
|
|
||||||
text_raw: str
|
|
||||||
text_norm: str
|
|
||||||
|
|
||||||
def normalize_text(s: str) -> str:
|
|
||||||
s = re.sub(r"\s+", " ", s.strip())
|
|
||||||
s = s.replace(" ", " ")
|
|
||||||
return s
|
|
||||||
|
|
||||||
BOOK_RE = re.compile(r"^(?P<book>[A-ZĂÂÎȘȚ][^\n]+?)\s*$")
|
|
||||||
CH_RE = re.compile(r"^(?i:Capitolul|CApitoLuL)\s+(?P<ch>\d+)\b")
|
|
||||||
VERSE_RE = re.compile(r"^(?P<v>\d+)\s+(?P<body>.+)$")
|
|
||||||
|
|
||||||
def parse_bible_md(md_text: str):
|
|
||||||
cur_book, cur_ch = None, None
|
|
||||||
testament = None
|
|
||||||
for line in md_text.splitlines():
|
|
||||||
line = line.rstrip()
|
|
||||||
|
|
||||||
# Book detection
|
|
||||||
m_book = BOOK_RE.match(line)
|
|
||||||
if m_book:
|
|
||||||
bname = m_book.group("book").strip()
|
|
||||||
if bname in BOOK_CANON:
|
|
||||||
cur_book = bname
|
|
||||||
testament = BOOK_CANON[bname]
|
|
||||||
cur_ch = None
|
|
||||||
continue
|
|
||||||
|
|
||||||
m_ch = CH_RE.match(line)
|
|
||||||
if m_ch and cur_book:
|
|
||||||
cur_ch = int(m_ch.group("ch"))
|
|
||||||
continue
|
|
||||||
|
|
||||||
m_v = VERSE_RE.match(line)
|
|
||||||
if m_v and cur_book and cur_ch:
|
|
||||||
vnum = int(m_v.group("v"))
|
|
||||||
body = m_v.group("body").strip()
|
|
||||||
raw = body
|
|
||||||
norm = normalize_text(body)
|
|
||||||
yield {
|
|
||||||
"testament": testament, "book": cur_book, "chapter": cur_ch, "verse": vnum,
|
|
||||||
"text_raw": raw, "text_norm": norm
|
|
||||||
}
|
|
||||||
|
|
||||||
async def embed_batch(client, inputs):
|
|
||||||
payload = {"input": inputs}
|
|
||||||
headers = {"api-key": AZ_API_KEY, "Content-Type": "application/json"}
|
|
||||||
for attempt in range(6):
|
|
||||||
try:
|
|
||||||
r = await client.post(EMBED_URL, headers=headers, json=payload, timeout=60)
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()
|
|
||||||
ordered = sorted(data["data"], key=lambda x: x["index"])
|
|
||||||
return [d["embedding"] for d in ordered]
|
|
||||||
elif r.status_code in (429, 500, 503):
|
|
||||||
backoff = 2 ** attempt + (0.1 * attempt)
|
|
||||||
await asyncio.sleep(backoff)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Embedding error {r.status_code}: {r.text}")
|
|
||||||
except Exception:
|
|
||||||
backoff = 2 ** attempt + (0.1 * attempt)
|
|
||||||
await asyncio.sleep(backoff)
|
|
||||||
raise RuntimeError("Failed to embed after retries")
|
|
||||||
|
|
||||||
UPSERT_SQL = """
|
|
||||||
INSERT INTO bible_passages (testament, book, chapter, verse, lang, translation, text_raw, text_norm, tsv, embedding)
|
|
||||||
VALUES (%(testament)s, %(book)s, %(chapter)s, %(verse)s, %(lang)s, %(translation)s, %(text_raw)s, %(text_norm)s,
|
|
||||||
to_tsvector(COALESCE(%(ts_lang)s,'simple')::regconfig, %(text_norm)s), %(embedding)s)
|
|
||||||
ON CONFLICT (translation, lang, book, chapter, verse) DO UPDATE
|
|
||||||
SET text_raw=EXCLUDED.text_raw,
|
|
||||||
text_norm=EXCLUDED.text_norm,
|
|
||||||
tsv=EXCLUDED.tsv,
|
|
||||||
embedding=EXCLUDED.embedding,
|
|
||||||
updated_at=now();
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
md_text = Path(BIBLE_MD_PATH).read_text(encoding="utf-8", errors="ignore")
|
|
||||||
verses = list(parse_bible_md(md_text))
|
|
||||||
print(f"Parsed verses: {len(verses)}")
|
|
||||||
|
|
||||||
batch_size = 128
|
|
||||||
async with httpx.AsyncClient() as client, psycopg.connect(DB_URL, autocommit=False) as conn:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
for i in range(0, len(verses), batch_size):
|
|
||||||
batch = verses[i:i+batch_size]
|
|
||||||
inputs = [v["text_norm"] for v in batch]
|
|
||||||
embs = await embed_batch(client, inputs)
|
|
||||||
rows = []
|
|
||||||
for v, e in zip(batch, embs):
|
|
||||||
rows.append({
|
|
||||||
**v,
|
|
||||||
"lang": os.getenv("LANG_CODE","ro"),
|
|
||||||
"translation": os.getenv("TRANSLATION_CODE","FIDELA"),
|
|
||||||
"ts_lang": "romanian",
|
|
||||||
"embedding": e
|
|
||||||
})
|
|
||||||
cur.executemany(UPSERT_SQL, rows)
|
|
||||||
conn.commit()
|
|
||||||
print(f"Upserted {len(rows)} … {i+len(rows)}/{len(verses)}")
|
|
||||||
print("Done. Build IVFFLAT index after ANALYZE.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import asyncio
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**
|
|
||||||
- If `romanian` text search config is unavailable, set `ts_lang='simple'`.
|
|
||||||
- For `embed-3-small`, set `EMBED_DIMS=1536` and change column type to `vector(1536)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Post‑ingestion steps
|
|
||||||
|
|
||||||
```sql
|
|
||||||
VACUUM ANALYZE bible_passages;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_vec_ivfflat
|
|
||||||
ON bible_passages USING ivfflat (embedding vector_cosine_ops)
|
|
||||||
WITH (lists = 200);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_book_ch ON bible_passages (book, chapter);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Retrieval patterns
|
|
||||||
|
|
||||||
### A) Pure vector kNN (cosine)
|
|
||||||
```sql
|
|
||||||
SELECT ref, book, chapter, verse, text_raw,
|
|
||||||
1 - (embedding <=> $1) AS cosine_sim
|
|
||||||
FROM bible_passages
|
|
||||||
ORDER BY embedding <=> $1
|
|
||||||
LIMIT $2;
|
|
||||||
```
|
|
||||||
|
|
||||||
### B) Hybrid lexical + vector (weighted)
|
|
||||||
```sql
|
|
||||||
WITH v AS (
|
|
||||||
SELECT id, 1 - (embedding <=> $1) AS vsim
|
|
||||||
FROM bible_passages
|
|
||||||
ORDER BY embedding <=> $1
|
|
||||||
LIMIT 100
|
|
||||||
),
|
|
||||||
l AS (
|
|
||||||
SELECT id, ts_rank(tsv, $2) AS lrank
|
|
||||||
FROM bible_passages
|
|
||||||
WHERE tsv @@ $2
|
|
||||||
)
|
|
||||||
SELECT bp.ref, bp.book, bp.chapter, bp.verse, bp.text_raw,
|
|
||||||
COALESCE(v.vsim, 0) * 0.7 + COALESCE(l.lrank, 0) * 0.3 AS score
|
|
||||||
FROM bible_passages bp
|
|
||||||
LEFT JOIN v ON v.id = bp.id
|
|
||||||
LEFT JOIN l ON l.id = bp.id
|
|
||||||
ORDER BY score DESC
|
|
||||||
LIMIT 20;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Chat & Prayer agent tips
|
|
||||||
|
|
||||||
- **Answer grounding**: always cite `ref` (e.g., *Ioan 3:16*).
|
|
||||||
- **Multilingual output**: keep quotes in Romanian; explain in the user’s language.
|
|
||||||
- **Prayer agent**: constrain tone & doctrine; inject retrieved verses as anchors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Ops
|
|
||||||
|
|
||||||
- Idempotent `UPSERT`.
|
|
||||||
- Backoff on 429/5xx.
|
|
||||||
- Consider keeping both `embed-3-large` and `-small` columns when migrating.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9) License & attribution
|
|
||||||
|
|
||||||
This guide references the structure of *Biblia Fidela* Markdown for ingestion demonstration. fileciteturn0file0
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,304 +0,0 @@
|
|||||||
# BiblicalGuide Homepage Sections
|
|
||||||
|
|
||||||
## Current Sections ✓
|
|
||||||
1. Hero/CTA (Read Bible & AI Chat)
|
|
||||||
2. Features Overview
|
|
||||||
3. Basic Stats
|
|
||||||
|
|
||||||
## Recommended Additional Sections (In Order)
|
|
||||||
|
|
||||||
### 1. **Hero Section Enhancement** (Update Current)
|
|
||||||
```
|
|
||||||
Headline: "Your Personal Biblical Guide"
|
|
||||||
Subheadline: "Explore Scripture with AI-Powered Insights in [Language]"
|
|
||||||
|
|
||||||
[Start Reading] [Chat with AI Guide]
|
|
||||||
|
|
||||||
Live Counter: "Join 2,847 believers studying God's word right now"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Interactive Demo Section** 🌟
|
|
||||||
```
|
|
||||||
Title: "See It In Action"
|
|
||||||
|
|
||||||
[Live Chat Widget Preview]
|
|
||||||
User: "What does the Bible say about hope?"
|
|
||||||
AI: "The Bible offers many encouraging verses about hope..."
|
|
||||||
|
|
||||||
[Try It Yourself - No Sign Up Required]
|
|
||||||
```
|
|
||||||
**Why:** Reduce friction - let users experience the AI immediately without commitment
|
|
||||||
|
|
||||||
### 3. **Daily Verse Section** 📖
|
|
||||||
```
|
|
||||||
Today's Verse - January 15, 2024
|
|
||||||
|
|
||||||
"For I know the plans I have for you," declares the Lord,
|
|
||||||
"plans to prosper you and not to harm you,
|
|
||||||
plans to give you hope and a future."
|
|
||||||
- Jeremiah 29:11
|
|
||||||
|
|
||||||
[💬 Discuss This Verse] [🔖 Save] [📤 Share]
|
|
||||||
|
|
||||||
Tomorrow: Get daily verses delivered to your inbox [Subscribe]
|
|
||||||
```
|
|
||||||
**Why:** Gives immediate value and a reason to return daily
|
|
||||||
|
|
||||||
### 4. **Popular Questions Carousel** 💭
|
|
||||||
```
|
|
||||||
What Others Are Asking
|
|
||||||
|
|
||||||
[< ] "What does the Bible say about anxiety?"
|
|
||||||
"Understanding God's plan for my life"
|
|
||||||
"How to pray effectively"
|
|
||||||
"Finding peace in difficult times" [ >]
|
|
||||||
|
|
||||||
[Ask Your Own Question →]
|
|
||||||
```
|
|
||||||
**Why:** Shows real use cases and inspires engagement
|
|
||||||
|
|
||||||
### 5. **How It Works** (3 Steps) 🎯
|
|
||||||
```
|
|
||||||
Start Your Biblical Journey
|
|
||||||
|
|
||||||
1. Ask Any Question
|
|
||||||
Type or speak your spiritual questions
|
|
||||||
[Icon: Chat bubble]
|
|
||||||
|
|
||||||
2. Get Biblical Answers
|
|
||||||
Receive verses and insights instantly
|
|
||||||
[Icon: Book with sparkle]
|
|
||||||
|
|
||||||
3. Grow in Faith
|
|
||||||
Save insights, track your journey
|
|
||||||
[Icon: Growth chart]
|
|
||||||
|
|
||||||
[Get Started Free →]
|
|
||||||
```
|
|
||||||
**Why:** Reduces complexity anxiety for non-tech users
|
|
||||||
|
|
||||||
### 6. **Community Prayer Wall** (Live) 🙏
|
|
||||||
```
|
|
||||||
Prayer Requests From Our Community
|
|
||||||
|
|
||||||
"Please pray for my mother's health..." - 2 min ago
|
|
||||||
[🙏 32 Praying]
|
|
||||||
|
|
||||||
"Seeking guidance for my job interview..." - 15 min ago
|
|
||||||
[🙏 47 Praying]
|
|
||||||
|
|
||||||
"Thankful for answered prayers!" - 1 hour ago
|
|
||||||
[❤️ 89 Celebrating]
|
|
||||||
|
|
||||||
[Share Your Prayer Request] [View All Prayers]
|
|
||||||
```
|
|
||||||
**Why:** Creates immediate sense of community and belonging
|
|
||||||
|
|
||||||
### 7. **Trust Indicators Section** ✅
|
|
||||||
```
|
|
||||||
Trusted by Believers Worldwide
|
|
||||||
|
|
||||||
✓ Theologically Verified
|
|
||||||
Reviewed by seminary professors and pastors
|
|
||||||
|
|
||||||
✓ 100% Private & Secure
|
|
||||||
Your spiritual journey stays between you and God
|
|
||||||
|
|
||||||
✓ Always Free Core Features
|
|
||||||
Essential tools available to everyone
|
|
||||||
|
|
||||||
✓ Multi-Language Support
|
|
||||||
Available in 25+ languages
|
|
||||||
|
|
||||||
[Logos of: Christianity Today | Bible Society | Local Churches]
|
|
||||||
```
|
|
||||||
**Why:** Builds credibility, especially important for faith-based apps
|
|
||||||
|
|
||||||
### 8. **Use Cases / Who It's For** 👥
|
|
||||||
```
|
|
||||||
Perfect For Every Believer
|
|
||||||
|
|
||||||
[Tab: New Christians]
|
|
||||||
Start your faith journey with gentle guidance
|
|
||||||
- Basic Bible navigation
|
|
||||||
- Simple explanations
|
|
||||||
- Foundational topics
|
|
||||||
|
|
||||||
[Tab: Bible Students]
|
|
||||||
Deep dive into Scripture
|
|
||||||
- Original language insights
|
|
||||||
- Historical context
|
|
||||||
- Cross-references
|
|
||||||
|
|
||||||
[Tab: Parents & Teachers]
|
|
||||||
Share faith with the next generation
|
|
||||||
- Age-appropriate answers
|
|
||||||
- Discussion guides
|
|
||||||
- Family devotionals
|
|
||||||
|
|
||||||
[Tab: Pastors & Leaders]
|
|
||||||
Enhance your ministry
|
|
||||||
- Sermon preparation
|
|
||||||
- Counseling support
|
|
||||||
- Quick references
|
|
||||||
```
|
|
||||||
**Why:** Helps visitors self-identify and see personalized value
|
|
||||||
|
|
||||||
### 9. **Testimonials with Context** 💬
|
|
||||||
```
|
|
||||||
Real Stories from Our Community
|
|
||||||
|
|
||||||
"As a new mom with little time, the AI chat helps me stay
|
|
||||||
connected to God's word during 3am feedings."
|
|
||||||
- Sarah M., Mother of Two | Using for 3 months
|
|
||||||
|
|
||||||
"I was skeptical about AI and faith, but the Biblical accuracy
|
|
||||||
and thoughtful responses exceeded my expectations."
|
|
||||||
- Pastor David K., Baptist Church | Verified Minister
|
|
||||||
|
|
||||||
"Finally, a Bible app that speaks my language - literally!
|
|
||||||
The Portuguese support is perfect."
|
|
||||||
- João S., Brazil | International User
|
|
||||||
|
|
||||||
[Share Your Story]
|
|
||||||
```
|
|
||||||
**Why:** Social proof with relatable contexts
|
|
||||||
|
|
||||||
### 10. **FAQ Section** (Expandable) ❓
|
|
||||||
```
|
|
||||||
Common Questions
|
|
||||||
|
|
||||||
▼ Is the AI theologically accurate?
|
|
||||||
▼ Is it really free?
|
|
||||||
▼ What languages are supported?
|
|
||||||
▼ Can I use it offline?
|
|
||||||
▼ Is my data private?
|
|
||||||
▼ What Bible versions are available?
|
|
||||||
|
|
||||||
[Contact Support] [View All FAQs]
|
|
||||||
```
|
|
||||||
**Why:** Addresses objections before they become barriers
|
|
||||||
|
|
||||||
### 11. **Mobile App CTA** (If Applicable) 📱
|
|
||||||
```
|
|
||||||
Take Your Faith Everywhere
|
|
||||||
|
|
||||||
[App Store] [Google Play]
|
|
||||||
|
|
||||||
⭐⭐⭐⭐⭐ 4.8 rating | 10K+ downloads
|
|
||||||
|
|
||||||
QR Code: [Scan to Download]
|
|
||||||
```
|
|
||||||
**Why:** Many users prefer apps for daily spiritual practices
|
|
||||||
|
|
||||||
### 12. **Footer Enhancement** (Update Current)
|
|
||||||
```
|
|
||||||
Newsletter Signup:
|
|
||||||
"Daily Wisdom in Your Inbox"
|
|
||||||
[Email] [Subscribe]
|
|
||||||
|
|
||||||
Quick Links:
|
|
||||||
About | Blog | Support | API Docs
|
|
||||||
|
|
||||||
Legal:
|
|
||||||
Terms | Privacy | Cookie Policy | GDPR
|
|
||||||
|
|
||||||
Languages: [EN] [ES] [PT] [FR] [DE] [+20 more]
|
|
||||||
|
|
||||||
Social: [Facebook] [Instagram] [YouTube] [Twitter]
|
|
||||||
|
|
||||||
© 2024 BiblicalGuide - Made with ❤️ and 🙏
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optional Advanced Sections
|
|
||||||
|
|
||||||
### **Interactive Bible Map** 🗺️
|
|
||||||
- Visual journey through Biblical locations
|
|
||||||
- "Explore where Jesus walked"
|
|
||||||
- Great for engagement but development-heavy
|
|
||||||
|
|
||||||
### **Live Study Groups** 👥
|
|
||||||
- "3 groups studying Romans right now"
|
|
||||||
- "Join a study group" CTA
|
|
||||||
- Requires more complex infrastructure
|
|
||||||
|
|
||||||
### **Verse of the Day Widget**
|
|
||||||
- Embeddable widget code
|
|
||||||
- "Add to your website"
|
|
||||||
- Good for viral growth
|
|
||||||
|
|
||||||
## Homepage Section Priority
|
|
||||||
|
|
||||||
### Must Have (Launch):
|
|
||||||
1. Enhanced Hero
|
|
||||||
2. Interactive Demo
|
|
||||||
3. How It Works
|
|
||||||
4. Trust Indicators
|
|
||||||
5. Enhanced Footer
|
|
||||||
|
|
||||||
### Should Have (Week 2):
|
|
||||||
6. Daily Verse
|
|
||||||
7. Popular Questions
|
|
||||||
8. Community Prayer Wall
|
|
||||||
9. FAQ
|
|
||||||
|
|
||||||
### Nice to Have (Month 2):
|
|
||||||
10. Use Cases
|
|
||||||
11. Testimonials
|
|
||||||
12. Mobile App CTA
|
|
||||||
|
|
||||||
## Design Principles
|
|
||||||
|
|
||||||
### Above the Fold:
|
|
||||||
- **Hero with clear value prop**
|
|
||||||
- **Two primary CTAs** (Read & Chat)
|
|
||||||
- **Trust indicator** (user count or endorsement)
|
|
||||||
- **Interactive demo** teaser
|
|
||||||
|
|
||||||
### Middle Sections:
|
|
||||||
- **Build trust** progressively
|
|
||||||
- **Show community** presence
|
|
||||||
- **Address objections** naturally
|
|
||||||
- **Provide value** immediately
|
|
||||||
|
|
||||||
### Bottom Sections:
|
|
||||||
- **Capture emails** for retention
|
|
||||||
- **Social proof** for hesitant users
|
|
||||||
- **Resources** for power users
|
|
||||||
- **Legal/trust** information
|
|
||||||
|
|
||||||
## A/B Testing Opportunities
|
|
||||||
|
|
||||||
1. **Hero Headlines**
|
|
||||||
- "Your Personal Biblical Guide"
|
|
||||||
- "Chat with Scripture"
|
|
||||||
- "AI-Powered Bible Study"
|
|
||||||
|
|
||||||
2. **Primary CTA**
|
|
||||||
- "Start Free"
|
|
||||||
- "Chat Now"
|
|
||||||
- "Explore Scripture"
|
|
||||||
|
|
||||||
3. **Demo Placement**
|
|
||||||
- Above fold vs below
|
|
||||||
- Auto-play vs click-to-start
|
|
||||||
|
|
||||||
4. **Social Proof Type**
|
|
||||||
- User count vs testimonials
|
|
||||||
- Ratings vs endorsements
|
|
||||||
|
|
||||||
## Mobile Considerations
|
|
||||||
|
|
||||||
- **Collapse sections** to accordions
|
|
||||||
- **Sticky CTA** at bottom
|
|
||||||
- **Swipeable** testimonials
|
|
||||||
- **Simplified** prayer wall
|
|
||||||
- **Touch-optimized** demo
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
- **Lazy load** below-fold sections
|
|
||||||
- **Static cache** daily verse
|
|
||||||
- **WebSocket** for live prayer wall
|
|
||||||
- **Skeleton screens** while loading
|
|
||||||
- **Progressive enhancement** for demo
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
# Multi-Language Support Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Add comprehensive multi-language support to the Ghid Biblic application, starting with English as the second language alongside Romanian.
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
- **Database**: Already supports multiple languages (`lang` field) and translations (`translation` field)
|
|
||||||
- **Frontend**: Hardcoded Romanian interface
|
|
||||||
- **Vector Search**: Romanian-only search logic
|
|
||||||
- **Bible Data**: Only Romanian (FIDELA) version imported
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Core Infrastructure
|
|
||||||
1. **Install i18n Framework**
|
|
||||||
- Add `next-intl` for Next.js internationalization
|
|
||||||
- Configure locale routing (`/ro/`, `/en/`)
|
|
||||||
- Set up translation file structure
|
|
||||||
|
|
||||||
2. **Language Configuration**
|
|
||||||
- Create language detection and switching logic
|
|
||||||
- Add language persistence (localStorage/cookies)
|
|
||||||
- Configure default language fallbacks
|
|
||||||
|
|
||||||
3. **Translation Files Structure**
|
|
||||||
```
|
|
||||||
messages/
|
|
||||||
├── ro.json (Romanian - existing content)
|
|
||||||
├── en.json (English translations)
|
|
||||||
└── common.json (shared terms)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: UI Internationalization
|
|
||||||
1. **Navigation Component**
|
|
||||||
- Translate all menu items and labels
|
|
||||||
- Add language switcher dropdown
|
|
||||||
- Update routing for locale-aware navigation
|
|
||||||
|
|
||||||
2. **Chat Interface**
|
|
||||||
- Translate all UI text and prompts
|
|
||||||
- Add suggested questions per language
|
|
||||||
- Update loading states and error messages
|
|
||||||
|
|
||||||
3. **Page Content**
|
|
||||||
- Home page (`/` → `/[locale]/`)
|
|
||||||
- Bible browser (`/bible` → `/[locale]/bible`)
|
|
||||||
- Search page (`/search` → `/[locale]/search`)
|
|
||||||
- Prayer requests (`/prayers` → `/[locale]/prayers`)
|
|
||||||
|
|
||||||
### Phase 3: Backend Localization
|
|
||||||
1. **Vector Search Updates**
|
|
||||||
- Modify search functions to filter by language
|
|
||||||
- Add language parameter to search APIs
|
|
||||||
- Update hybrid search for language-specific full-text search
|
|
||||||
|
|
||||||
2. **Chat API Enhancement**
|
|
||||||
- Language-aware Bible verse retrieval
|
|
||||||
- Localized AI response prompts
|
|
||||||
- Language-specific fallback responses
|
|
||||||
|
|
||||||
3. **API Route Updates**
|
|
||||||
- Add locale parameter to all API endpoints
|
|
||||||
- Update error responses for each language
|
|
||||||
- Configure language-specific search configurations
|
|
||||||
|
|
||||||
### Phase 4: Bible Data Management
|
|
||||||
1. **English Bible Import**
|
|
||||||
- Source: API.Bible or public domain English Bible (KJV/ESV)
|
|
||||||
- Adapt existing import script for English
|
|
||||||
- Generate English embeddings using Azure OpenAI
|
|
||||||
|
|
||||||
2. **Language-Aware Bible Browser**
|
|
||||||
- Add language selector in Bible interface
|
|
||||||
- Filter books/chapters/verses by selected language
|
|
||||||
- Show parallel verses when both languages available
|
|
||||||
|
|
||||||
### Phase 5: Enhanced Features
|
|
||||||
1. **Parallel Bible View**
|
|
||||||
- Side-by-side Romanian/English verse display
|
|
||||||
- Cross-reference linking between translations
|
|
||||||
- Language comparison in search results
|
|
||||||
|
|
||||||
2. **Smart Language Detection**
|
|
||||||
- Auto-detect query language in chat
|
|
||||||
- Suggest language switch based on user input
|
|
||||||
- Mixed-language search capabilities
|
|
||||||
|
|
||||||
3. **Advanced Search Features**
|
|
||||||
- Cross-language semantic search
|
|
||||||
- Translation comparison tools
|
|
||||||
- Language-specific biblical term glossaries
|
|
||||||
|
|
||||||
## Technical Implementation Details
|
|
||||||
|
|
||||||
### Routing Structure
|
|
||||||
```
|
|
||||||
Current: /page
|
|
||||||
New: /[locale]/page
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- /ro/biblia (Romanian Bible)
|
|
||||||
- /en/bible (English Bible)
|
|
||||||
- /ro/rugaciuni (Romanian Prayers)
|
|
||||||
- /en/prayers (English Prayers)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Schema Changes
|
|
||||||
**No changes needed** - current schema already supports:
|
|
||||||
- Multiple languages via `lang` field
|
|
||||||
- Multiple translations via `translation` field
|
|
||||||
- Unique constraints per translation/language
|
|
||||||
|
|
||||||
### Vector Search Updates
|
|
||||||
```typescript
|
|
||||||
// Current
|
|
||||||
searchBibleHybrid(query: string, limit: number)
|
|
||||||
|
|
||||||
// Enhanced
|
|
||||||
searchBibleHybrid(query: string, language: string, limit: number)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Translation File Structure
|
|
||||||
```json
|
|
||||||
// messages/en.json
|
|
||||||
{
|
|
||||||
"navigation": {
|
|
||||||
"home": "Home",
|
|
||||||
"bible": "Bible",
|
|
||||||
"prayers": "Prayers",
|
|
||||||
"search": "Search"
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"placeholder": "Ask your biblical question...",
|
|
||||||
"suggestions": [
|
|
||||||
"What does the Bible say about love?",
|
|
||||||
"Explain the parable of the sower",
|
|
||||||
"What are the fruits of the Spirit?"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Language Switcher Component
|
|
||||||
- Dropdown in navigation header
|
|
||||||
- Flag icons for visual identification
|
|
||||||
- Persist language choice across sessions
|
|
||||||
- Redirect to equivalent page in new language
|
|
||||||
|
|
||||||
## Dependencies to Add
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"next-intl": "^3.x",
|
|
||||||
"@formatjs/intl-localematcher": "^0.x",
|
|
||||||
"negotiator": "^0.x"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure Changes
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── [locale]/
|
|
||||||
│ ├── page.tsx
|
|
||||||
│ ├── bible/
|
|
||||||
│ ├── prayers/
|
|
||||||
│ ├── search/
|
|
||||||
│ └── layout.tsx
|
|
||||||
├── api/ (unchanged)
|
|
||||||
└── globals.css
|
|
||||||
|
|
||||||
messages/
|
|
||||||
├── en.json
|
|
||||||
├── ro.json
|
|
||||||
└── index.ts
|
|
||||||
|
|
||||||
components/
|
|
||||||
├── language-switcher.tsx
|
|
||||||
├── navigation.tsx (updated)
|
|
||||||
└── chat/ (updated)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
1. **Unit Tests**: Translation loading and language switching
|
|
||||||
2. **Integration Tests**: API endpoints with locale parameters
|
|
||||||
3. **E2E Tests**: Complete user flows in both languages
|
|
||||||
4. **Performance Tests**: Vector search with language filtering
|
|
||||||
|
|
||||||
## Rollout Plan
|
|
||||||
1. **Development**: Implement Phase 1-3 (core infrastructure and UI)
|
|
||||||
2. **Testing**: Deploy to staging with Romanian/English support
|
|
||||||
3. **Beta Release**: Limited user testing with feedback collection
|
|
||||||
4. **Production**: Full release with both languages
|
|
||||||
5. **Future**: Add additional languages based on user demand
|
|
||||||
|
|
||||||
## Estimated Timeline
|
|
||||||
- **Phase 1-2**: 2-3 days (i18n setup and UI translation)
|
|
||||||
- **Phase 3**: 1-2 days (backend localization)
|
|
||||||
- **Phase 4**: 2-3 days (English Bible import and embeddings)
|
|
||||||
- **Phase 5**: 3-4 days (enhanced features)
|
|
||||||
- **Total**: 8-12 days for complete implementation
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
- Language switching works seamlessly
|
|
||||||
- Vector search returns accurate results in both languages
|
|
||||||
- AI chat responses are contextually appropriate per language
|
|
||||||
- User can browse Bible in preferred language
|
|
||||||
- Performance remains optimal with language filtering
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
- Spanish, French, German language support
|
|
||||||
- Regional dialect variations
|
|
||||||
- Audio Bible integration per language
|
|
||||||
- Collaborative translation features for community contributions
|
|
||||||
Reference in New Issue
Block a user