Add Ollama embedding support and improve prayer system with public/private visibility

- Add Ollama fallback support in vector search with Azure OpenAI as primary
- Enhance prayer system with public/private visibility options and language filtering
- Update OG image to use new biblical-guide-og-image.png
- Improve prayer request management with better categorization
- Remove deprecated ingest_json_pgvector.py script

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-28 19:25:49 +00:00
parent 2d27eae756
commit e4b815cb40
8 changed files with 457 additions and 320 deletions

View File

@@ -16,6 +16,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
const currentUrl = locale === 'ro' ? 'https://biblical-guide.com/ro/' : 'https://biblical-guide.com/en/'
const alternateUrl = locale === 'ro' ? 'https://biblical-guide.com/en/' : 'https://biblical-guide.com/ro/'
const ogImageUrl = 'https://biblical-guide.com/biblical-guide-og-image.png'
return {
title: t('title'),
@@ -38,7 +39,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
type: 'website',
images: [
{
url: `https://ghidulbiblic.ro/og-image-${locale}.jpg`,
url: ogImageUrl,
width: 1200,
height: 630,
alt: t('ogTitle'),
@@ -50,7 +51,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
site: '@ghidbiblic',
title: t('twitterTitle'),
description: t('twitterDescription'),
images: [`https://ghidulbiblic.ro/og-image-${locale}.jpg`],
images: [ogImageUrl],
},
other: {
'application/ld+json': JSON.stringify({

View File

@@ -15,9 +15,6 @@ import {
DialogTitle,
DialogContent,
DialogActions,
List,
ListItem,
ListItemAvatar,
ListItemText,
MenuItem,
useTheme,
@@ -27,6 +24,10 @@ import {
Tabs,
Tab,
FormControlLabel,
FormControl,
Select,
Checkbox,
SelectChangeEvent,
Switch,
} from '@mui/material'
import {
@@ -42,7 +43,7 @@ import {
Edit,
Login,
} from '@mui/icons-material'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useTranslations, useLocale, useFormatter } from 'next-intl'
import { useAuth } from '@/hooks/use-auth'
@@ -55,6 +56,9 @@ interface PrayerRequest {
timestamp: Date
prayerCount: number
isPrayedFor: boolean
isPublic: boolean
language: string
isOwner: boolean
}
export default function PrayersPage() {
@@ -72,10 +76,50 @@ export default function PrayersPage() {
title: '',
description: '',
category: 'personal',
isPublic: false,
})
const [aiPrompt, setAiPrompt] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'private' | 'public'>(user ? 'private' : 'public')
const [selectedLanguages, setSelectedLanguages] = useState<string[]>([locale])
const languagesKey = useMemo(() => selectedLanguages.slice().sort().join(','), [selectedLanguages])
const languageOptions = useMemo(() => ([
{ value: 'en', label: t('languageFilter.options.en') },
{ value: 'ro', label: t('languageFilter.options.ro') }
]), [t])
const languageLabelMap = useMemo(() => (
languageOptions.reduce((acc, option) => {
acc[option.value] = option.label
return acc
}, {} as Record<string, string>)
), [languageOptions])
useEffect(() => {
if (user) {
setViewMode(prev => (prev === 'private' ? prev : 'private'))
} else {
setViewMode('public')
}
}, [user])
useEffect(() => {
if (viewMode === 'public') {
setSelectedLanguages(prev => {
if (prev.includes(locale)) {
return prev
}
return [...prev, locale]
})
}
}, [locale, viewMode])
useEffect(() => {
if (viewMode === 'public' && selectedLanguages.length === 0) {
setSelectedLanguages([locale])
}
}, [viewMode, selectedLanguages, locale])
const categories = [
{ value: 'personal', label: t('categories.personal'), color: 'primary' },
@@ -88,6 +132,12 @@ export default function PrayersPage() {
// Fetch prayers from API
const fetchPrayers = async () => {
if (viewMode === 'private' && !user) {
setPrayers([])
setLoading(false)
return
}
setLoading(true)
try {
const params = new URLSearchParams()
@@ -95,11 +145,25 @@ export default function PrayersPage() {
params.append('category', selectedCategory)
}
params.append('limit', '50')
if (user?.id) {
params.append('userId', user.id)
params.append('visibility', viewMode)
if (viewMode === 'public') {
const languagesToQuery = selectedLanguages.length > 0 ? selectedLanguages : [locale]
languagesToQuery.forEach(lang => params.append('languages', lang))
}
const response = await fetch(`/api/prayers?${params.toString()}`)
const headers: Record<string, string> = {}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
const response = await fetch(`/api/prayers?${params.toString()}`, {
headers
})
if (response.ok) {
const data = await response.json()
setPrayers(data.prayers.map((prayer: any) => ({
@@ -107,6 +171,9 @@ export default function PrayersPage() {
timestamp: new Date(prayer.timestamp)
})))
} else {
if (response.status === 401) {
setPrayers([])
}
console.error('Failed to fetch prayers')
}
} catch (error) {
@@ -118,7 +185,7 @@ export default function PrayersPage() {
useEffect(() => {
fetchPrayers()
}, [selectedCategory, user])
}, [selectedCategory, user, viewMode, languagesKey])
const handleGenerateAIPrayer = async () => {
if (!aiPrompt.trim()) return
@@ -144,7 +211,8 @@ export default function PrayersPage() {
setNewPrayer({
title: data.title || '',
description: data.prayer || '',
category: newPrayer.category
category: newPrayer.category,
isPublic: newPrayer.isPublic
})
setTabValue(0) // Switch to write tab to review generated prayer
} else {
@@ -157,43 +225,41 @@ export default function PrayersPage() {
}
}
const handleLanguageChange = (event: SelectChangeEvent<string[]>) => {
const value = event.target.value
const parsed = typeof value === 'string'
? value.split(',')
: (value as string[])
const uniqueValues = Array.from(new Set(parsed.filter(Boolean)))
setSelectedLanguages(uniqueValues)
}
const handleSubmitPrayer = async () => {
if (!newPrayer.title.trim() || !newPrayer.description.trim()) return
if (!user) return
const prayer: PrayerRequest = {
id: Date.now().toString(),
title: newPrayer.title,
description: newPrayer.description,
category: newPrayer.category,
author: user.name || (locale === 'en' ? 'You' : 'Tu'),
timestamp: new Date(),
prayerCount: 0,
isPrayedFor: false,
}
try {
const token = localStorage.getItem('authToken')
const response = await fetch('/api/prayers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify({
title: newPrayer.title,
description: newPrayer.description,
category: newPrayer.category,
isAnonymous: false
isAnonymous: false,
isPublic: newPrayer.isPublic,
language: locale
}),
})
if (response.ok) {
const data = await response.json()
setPrayers([{
...data.prayer,
timestamp: new Date(data.prayer.timestamp)
}, ...prayers])
setNewPrayer({ title: '', description: '', category: 'personal' })
await fetchPrayers()
setNewPrayer({ title: '', description: '', category: 'personal', isPublic: false })
setAiPrompt('')
setTabValue(0)
setOpenDialog(false)
@@ -341,6 +407,36 @@ export default function PrayersPage() {
))}
</Box>
{viewMode === 'public' && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t('languageFilter.title')}
</Typography>
<FormControl fullWidth size="small">
<Select
multiple
value={selectedLanguages}
onChange={handleLanguageChange}
renderValue={(selected) =>
(selected as string[])
.map(code => languageLabelMap[code] || code.toUpperCase())
.join(', ')
}
>
{languageOptions.map(option => (
<MenuItem key={option.value} value={option.value}>
<Checkbox checked={selectedLanguages.includes(option.value)} />
<ListItemText primary={option.label} />
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
{t('languageFilter.helper')}
</Typography>
</Box>
)}
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
{t('stats.title')}
</Typography>
@@ -355,6 +451,30 @@ export default function PrayersPage() {
{/* Prayer Requests */}
<Box sx={{ flex: 1, width: { xs: '100%', md: '75%' } }}>
{user && (
<Tabs
value={viewMode}
onChange={(_, newValue) => setViewMode(newValue as 'private' | 'public')}
sx={{ mb: 3 }}
variant="fullWidth"
>
<Tab value="private" label={t('viewModes.private')} />
<Tab value="public" label={t('viewModes.public')} />
</Tabs>
)}
{viewMode === 'private' && (
<Alert severity="info" sx={{ mb: 3 }}>
{t('alerts.privateInfo')}
</Alert>
)}
{viewMode === 'public' && !user && (
<Alert severity="info" sx={{ mb: 3 }}>
{t('alerts.publicInfo')}
</Alert>
)}
{loading ? (
<Box>
{Array.from({ length: 3 }).map((_, index) => (
@@ -388,23 +508,43 @@ export default function PrayersPage() {
</Box>
) : (
<Box>
{prayers.map((prayer) => {
{prayers.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
{viewMode === 'private' ? t('empty.private') : t('empty.public')}
</Typography>
</Paper>
) : prayers.map((prayer) => {
const categoryInfo = getCategoryInfo(prayer.category)
const authorName = prayer.isOwner ? (locale === 'en' ? 'You' : 'Tu') : prayer.author
const languageLabel = languageLabelMap[prayer.language] || prayer.language.toUpperCase()
return (
<Card key={prayer.id} sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="h6" component="h3">
{prayer.title}
</Typography>
<Typography variant="h6" component="h3">
{prayer.title}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1, mt: 1 }}>
<Chip
label={categoryInfo.label}
color={categoryInfo.color as any}
size="small"
variant="outlined"
/>
<Chip
label={prayer.isPublic ? t('chips.public') : t('chips.private')}
size="small"
color={prayer.isPublic ? 'success' : 'default'}
variant={prayer.isPublic ? 'filled' : 'outlined'}
/>
<Chip
label={languageLabel}
size="small"
variant="outlined"
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
@@ -413,7 +553,7 @@ export default function PrayersPage() {
<Person sx={{ fontSize: 16 }} />
</Avatar>
<Typography variant="body2" color="text.secondary">
{prayer.author}
{authorName}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -450,6 +590,7 @@ export default function PrayersPage() {
variant="outlined"
size="small"
startIcon={<Share />}
disabled={!prayer.isPublic}
>
{t('buttons.share')}
</Button>
@@ -602,6 +743,21 @@ export default function PrayersPage() {
)}
</Box>
)}
<Box sx={{ mt: 3 }}>
<FormControlLabel
control={
<Switch
checked={newPrayer.isPublic}
onChange={(event) => setNewPrayer({ ...newPrayer, isPublic: event.target.checked })}
/>
}
label={t('dialog.makePublic')}
/>
<Typography variant="caption" color="text.secondary" display="block">
{newPrayer.isPublic ? t('dialog.visibilityPublic') : t('dialog.visibilityPrivate')}
</Typography>
</Box>
</DialogContent>
<DialogActions>

View File

@@ -50,6 +50,8 @@ export async function GET(request: Request) {
category: true,
author: true,
isAnonymous: true,
isPublic: true,
language: true,
prayerCount: true,
isActive: true,
createdAt: true,
@@ -84,4 +86,4 @@ export async function GET(request: Request) {
{ status: 500 }
);
}
}
}