Add comprehensive page management system to admin dashboard

Features added:
- Database schema for pages and media files with content types (Rich Text, HTML, Markdown)
- Admin API routes for full page CRUD operations
- Image upload functionality with file management
- Rich text editor using TinyMCE with image insertion
- Admin interface for creating/editing pages with SEO options
- Dynamic navigation and footer integration
- Public page display routes with proper SEO metadata
- Support for featured images and content excerpts

Admin features:
- Create/edit/delete pages with rich content editor
- Upload and manage images through media library
- Configure pages to appear in navigation or footer
- Set page status (Draft, Published, Archived)
- SEO title and description management
- Real-time preview of content changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-24 07:26:25 +00:00
parent f81886a851
commit 95070e5369
53 changed files with 3628 additions and 206 deletions

View File

@@ -82,6 +82,14 @@ interface BibleChapter {
verses: BibleVerse[]
}
interface BibleVersion {
id: string
name: string
abbreviation: string
language: string
isDefault?: boolean
}
interface BibleBook {
id: string
versionId: string
@@ -122,10 +130,13 @@ export default function BibleReaderNew() {
// Core state
const [books, setBooks] = useState<BibleBook[]>([])
const [versions, setVersions] = useState<BibleVersion[]>([])
const [selectedVersion, setSelectedVersion] = useState<string>('')
const [selectedBook, setSelectedBook] = useState<string>('')
const [selectedChapter, setSelectedChapter] = useState<number>(1)
const [verses, setVerses] = useState<BibleVerse[]>([])
const [loading, setLoading] = useState(true)
const [versionsLoading, setVersionsLoading] = useState(true)
// UI state
const [settingsOpen, setSettingsOpen] = useState(false)
@@ -172,13 +183,29 @@ export default function BibleReaderNew() {
console.error('Failed to parse preferences:', e)
}
}
}, [])
// Load saved version preference
const savedVersion = localStorage.getItem('selectedBibleVersion')
if (savedVersion && versions.length > 0) {
const version = versions.find(v => v.id === savedVersion)
if (version) {
setSelectedVersion(savedVersion)
}
}
}, [versions])
// Save preferences to localStorage
useEffect(() => {
localStorage.setItem('bibleReaderPreferences', JSON.stringify(preferences))
}, [preferences])
// Save selected version to localStorage
useEffect(() => {
if (selectedVersion) {
localStorage.setItem('selectedBibleVersion', selectedVersion)
}
}, [selectedVersion])
// Scroll handler for show scroll to top button
useEffect(() => {
const handleScroll = () => {
@@ -189,29 +216,60 @@ export default function BibleReaderNew() {
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Fetch books
// Fetch versions based on current locale
useEffect(() => {
fetch(`/api/bible/books?locale=${locale}`)
setVersionsLoading(true)
fetch(`/api/bible/versions?language=${locale}`)
.then(res => res.json())
.then(data => {
setBooks(data.books || [])
if (data.books && data.books.length > 0) {
setSelectedBook(data.books[0].id)
if (data.success && data.versions) {
setVersions(data.versions)
// Select default version or first available
const defaultVersion = data.versions.find((v: BibleVersion) => v.isDefault) || data.versions[0]
if (defaultVersion) {
setSelectedVersion(defaultVersion.id)
}
}
setLoading(false)
setVersionsLoading(false)
})
.catch(err => {
console.error('Error fetching books:', err)
setLoading(false)
console.error('Error fetching versions:', err)
setVersionsLoading(false)
})
}, [locale])
// Fetch books when version changes
useEffect(() => {
if (selectedVersion) {
setLoading(true)
fetch(`/api/bible/books?locale=${locale}&version=${selectedVersion}`)
.then(res => res.json())
.then(data => {
setBooks(data.books || [])
if (data.books && data.books.length > 0) {
setSelectedBook(data.books[0].id)
}
setLoading(false)
})
.catch(err => {
console.error('Error fetching books:', err)
setLoading(false)
})
}
}, [locale, selectedVersion])
// Handle URL parameters
useEffect(() => {
if (books.length > 0) {
if (books.length > 0 && versions.length > 0) {
const bookParam = searchParams.get('book')
const chapterParam = searchParams.get('chapter')
const verseParam = searchParams.get('verse')
const versionParam = searchParams.get('version')
// Handle version parameter
if (versionParam && versions.find(v => v.id === versionParam)) {
setSelectedVersion(versionParam)
}
if (bookParam) {
const book = books.find(b => b.id === bookParam) || books.find(b => b.bookKey === bookParam)
@@ -236,7 +294,7 @@ export default function BibleReaderNew() {
}
}
}
}, [books, searchParams])
}, [books, versions, searchParams])
// Fetch verses when book/chapter changes
useEffect(() => {
@@ -350,10 +408,13 @@ export default function BibleReaderNew() {
const currentBook = books.find(book => book.id === selectedBook)
const maxChapters = currentBook?.chapters?.length || 1
const updateUrl = (bookId: string, chapter: number) => {
const updateUrl = (bookId: string, chapter: number, version?: string) => {
const url = new URL(window.location.href)
url.searchParams.set('book', bookId)
url.searchParams.set('chapter', chapter.toString())
if (version) {
url.searchParams.set('version', version)
}
window.history.replaceState({}, '', url.toString())
}
@@ -368,7 +429,7 @@ export default function BibleReaderNew() {
if (selectedChapter > 1) {
const newChapter = selectedChapter - 1
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
} else {
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
if (currentBookIndex > 0) {
@@ -376,7 +437,7 @@ export default function BibleReaderNew() {
const lastChapter = previousBook.chapters?.length || 1
setSelectedBook(previousBook.id)
setSelectedChapter(lastChapter)
updateUrl(previousBook.id, lastChapter)
updateUrl(previousBook.id, lastChapter, selectedVersion)
}
}
}
@@ -385,14 +446,14 @@ export default function BibleReaderNew() {
if (selectedChapter < maxChapters) {
const newChapter = selectedChapter + 1
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
} else {
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
if (currentBookIndex < books.length - 1) {
const nextBook = books[currentBookIndex + 1]
setSelectedBook(nextBook.id)
setSelectedChapter(1)
updateUrl(nextBook.id, 1)
updateUrl(nextBook.id, 1, selectedVersion)
}
}
}
@@ -493,7 +554,7 @@ export default function BibleReaderNew() {
}
const handleShare = () => {
const url = `${window.location.origin}/${locale}/bible?book=${selectedBook}&chapter=${selectedChapter}`
const url = `${window.location.origin}/${locale}/bible?book=${selectedBook}&chapter=${selectedChapter}&version=${selectedVersion}`
navigator.clipboard.writeText(url).then(() => {
setCopyFeedback({
open: true,
@@ -614,6 +675,33 @@ export default function BibleReaderNew() {
}}
>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
{/* Version Selection */}
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 180, md: 200 } }}>
<FormControl fullWidth size="small">
<InputLabel>{t('version')}</InputLabel>
<Select
value={selectedVersion}
label={t('version')}
onChange={(e) => {
setSelectedVersion(e.target.value)
// Reset to first book when version changes
if (books.length > 0) {
setSelectedBook(books[0].id)
setSelectedChapter(1)
updateUrl(books[0].id, 1, e.target.value)
}
}}
disabled={versionsLoading}
>
{versions.map((version) => (
<MenuItem key={version.id} value={version.id}>
{version.abbreviation} - {version.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{/* Books Selection */}
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 200, md: 250 } }}>
<FormControl fullWidth size="small">
@@ -624,7 +712,7 @@ export default function BibleReaderNew() {
onChange={(e) => {
setSelectedBook(e.target.value)
setSelectedChapter(1)
updateUrl(e.target.value, 1)
updateUrl(e.target.value, 1, selectedVersion)
}}
>
{books.map((book) => (
@@ -646,7 +734,7 @@ export default function BibleReaderNew() {
onChange={(e) => {
const newChapter = Number(e.target.value)
setSelectedChapter(newChapter)
updateUrl(selectedBook, newChapter)
updateUrl(selectedBook, newChapter, selectedVersion)
}}
MenuProps={{
PaperProps: {

View File

@@ -0,0 +1,281 @@
'use client'
import {
Container,
Card,
CardContent,
Typography,
Box,
Button,
TextField,
Paper,
useTheme,
Alert,
Snackbar,
} from '@mui/material'
import {
Email,
LocationOn,
Send,
ContactSupport,
} from '@mui/icons-material'
import { useRouter } from 'next/navigation'
import { useTranslations, useLocale } from 'next-intl'
import { useState } from 'react'
export default function Contact() {
const theme = useTheme()
const router = useRouter()
const t = useTranslations('contact')
const locale = useLocale()
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: ''
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const [showError, setShowError] = useState(false)
const handleInputChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[field]: event.target.value
}))
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
setIsSubmitting(true)
try {
// Simulate form submission
await new Promise(resolve => setTimeout(resolve, 1000))
// Here you would typically send the data to your API
console.log('Form submitted:', formData)
setFormData({
name: '',
email: '',
subject: '',
message: ''
})
setShowSuccess(true)
} catch (error) {
setShowError(true)
} finally {
setIsSubmitting(false)
}
}
const contactInfo = [
{
icon: <Email sx={{ fontSize: 30, color: 'primary.main' }} />,
title: t('info.email.title'),
content: t('info.email.content'),
action: 'mailto:contact@biblical-guide.com'
},
{
icon: <LocationOn sx={{ fontSize: 30, color: 'primary.main' }} />,
title: t('info.address.title'),
content: t('info.address.content'),
action: null
}
]
return (
<Box sx={{ py: 4 }}>
{/* Hero Section */}
<Box
sx={{
background: 'linear-gradient(135deg, #009688 0%, #00796B 100%)',
color: 'white',
py: 8,
mb: 6,
}}
>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center' }}>
<ContactSupport sx={{ fontSize: 80, mb: 2, opacity: 0.9 }} />
<Typography variant="h2" component="h1" gutterBottom>
{t('hero.title')}
</Typography>
<Typography variant="h5" component="h2" sx={{ mb: 2, opacity: 0.9 }}>
{t('hero.subtitle')}
</Typography>
<Typography variant="body1" sx={{ opacity: 0.8, maxWidth: 600, mx: 'auto' }}>
{t('hero.description')}
</Typography>
</Box>
</Container>
</Box>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{/* Contact Form */}
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 65%' } }}>
<Card sx={{ height: 'fit-content' }}>
<CardContent sx={{ p: 4 }}>
<Typography variant="h4" component="h2" gutterBottom>
{t('form.title')}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
{t('form.description')}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField
fullWidth
required
label={t('form.fields.name')}
value={formData.name}
onChange={handleInputChange('name')}
variant="outlined"
sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)' } }}
/>
<TextField
fullWidth
required
type="email"
label={t('form.fields.email')}
value={formData.email}
onChange={handleInputChange('email')}
variant="outlined"
sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)' } }}
/>
</Box>
<TextField
fullWidth
required
label={t('form.fields.subject')}
value={formData.subject}
onChange={handleInputChange('subject')}
variant="outlined"
/>
<TextField
fullWidth
required
multiline
rows={6}
label={t('form.fields.message')}
value={formData.message}
onChange={handleInputChange('message')}
variant="outlined"
/>
<Box>
<Button
type="submit"
variant="contained"
size="large"
disabled={isSubmitting}
startIcon={<Send />}
sx={{ minWidth: 200 }}
>
{isSubmitting ? t('form.submitting') : t('form.submit')}
</Button>
</Box>
</Box>
</CardContent>
</Card>
</Box>
{/* Contact Information */}
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 35%' } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="h4" component="h2">
{t('info.title')}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
{t('info.description')}
</Typography>
{contactInfo.map((info, index) => (
<Paper
key={index}
sx={{
p: 3,
display: 'flex',
alignItems: 'flex-start',
gap: 2,
cursor: info.action ? 'pointer' : 'default',
transition: 'transform 0.2s ease-in-out',
'&:hover': info.action ? {
transform: 'translateY(-2px)',
boxShadow: 2
} : {}
}}
onClick={() => info.action && window.open(info.action, '_self')}
>
<Box sx={{ flexShrink: 0 }}>
{info.icon}
</Box>
<Box>
<Typography variant="h6" sx={{ mb: 1 }}>
{info.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{info.content}
</Typography>
</Box>
</Paper>
))}
{/* FAQ Quick Link */}
<Paper sx={{ p: 3, bgcolor: 'primary.light', color: 'white' }}>
<Typography variant="h6" sx={{ mb: 2 }}>
{t('faq.title')}
</Typography>
<Typography variant="body2" sx={{ mb: 3, opacity: 0.9 }}>
{t('faq.description')}
</Typography>
<Button
variant="outlined"
sx={{
color: 'white',
borderColor: 'white',
'&:hover': {
borderColor: 'white',
bgcolor: 'rgba(255,255,255,0.1)'
}
}}
onClick={() => router.push(`/${locale}#faq`)}
>
{t('faq.viewFaq')}
</Button>
</Paper>
</Box>
</Box>
</Box>
</Container>
{/* Success/Error Messages */}
<Snackbar
open={showSuccess}
autoHideDuration={6000}
onClose={() => setShowSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setShowSuccess(false)} severity="success" sx={{ width: '100%' }}>
{t('form.success')}
</Alert>
</Snackbar>
<Snackbar
open={showError}
autoHideDuration={6000}
onClose={() => setShowError(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setShowError(false)} severity="error" sx={{ width: '100%' }}>
{t('form.error')}
</Alert>
</Snackbar>
</Box>
)
}

View File

@@ -6,6 +6,7 @@ import { notFound } from 'next/navigation'
import { MuiThemeProvider } from '@/components/providers/theme-provider'
import { AuthProvider } from '@/components/auth/auth-provider'
import { Navigation } from '@/components/layout/navigation'
import { Footer } from '@/components/layout/footer'
import FloatingChat from '@/components/chat/floating-chat'
import { merriweather, lato } from '@/lib/fonts'
@@ -32,7 +33,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
title: t('ogTitle'),
description: t('ogDescription'),
url: currentUrl,
siteName: locale === 'ro' ? 'Ghid Biblic' : 'Biblical Guide',
siteName: 'Biblical Guide',
locale: locale,
type: 'website',
images: [
@@ -106,6 +107,7 @@ export default async function LocaleLayout({
<AuthProvider>
<Navigation />
{children}
<Footer />
<FloatingChat />
</AuthProvider>
</MuiThemeProvider>

View File

@@ -14,7 +14,6 @@ import {
TextField,
Chip,
Avatar,
Divider,
IconButton,
Tooltip,
} from '@mui/material'
@@ -31,10 +30,6 @@ import {
Bookmark,
TrendingUp,
QuestionAnswer,
Facebook,
Twitter,
Instagram,
YouTube,
} from '@mui/icons-material'
import { useRouter } from 'next/navigation'
import { useTranslations, useLocale } from 'next-intl'
@@ -613,98 +608,6 @@ export default function Home() {
</Container>
</Paper>
{/* Enhanced Footer */}
<Paper component="footer" sx={{ bgcolor: 'grey.900', color: 'white', py: 6 }}>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'space-between', mb: 4 }}>
{/* Brand */}
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
{t('footer.brand')}
</Typography>
<Typography variant="body2" color="grey.400" sx={{ maxWidth: 300 }}>
{tSeo('footer')}
</Typography>
</Box>
{/* Quick Links */}
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
<Typography variant="h6" sx={{ mb: 2 }}>
{t('footer.quickLinks.title')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.about')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.blog')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.support')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.api')}
</Button>
</Box>
</Box>
{/* Legal */}
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
<Typography variant="h6" sx={{ mb: 2 }}>
{t('footer.legal.title')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.terms')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.privacy')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.cookies')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.gdpr')}
</Button>
</Box>
</Box>
{/* Social */}
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
<Typography variant="h6" sx={{ mb: 2 }}>
{t('footer.social.title')}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton color="inherit" size="small">
<Facebook />
</IconButton>
<IconButton color="inherit" size="small">
<Twitter />
</IconButton>
<IconButton color="inherit" size="small">
<Instagram />
</IconButton>
<IconButton color="inherit" size="small">
<YouTube />
</IconButton>
</Box>
</Box>
</Box>
<Divider sx={{ bgcolor: 'grey.700', mb: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="body2" color="grey.400">
© {getCurrentYear()} {locale === 'ro' ? 'Ghid Biblic - Făcut cu ❤️ și 🙏' : 'Biblical Guide - Made with ❤️ and 🙏'}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label="🇷🇴 Română" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
<Chip label="🇺🇸 English" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
<Chip label="+20 more" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
</Box>
</Box>
</Container>
</Paper>
</Box>
)
}

View File

@@ -0,0 +1,309 @@
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import {
Container,
Typography,
Box,
Paper,
Breadcrumbs,
Link,
Avatar
} from '@mui/material'
import {
Home as HomeIcon,
Article as ArticleIcon,
CalendarToday as DateIcon
} from '@mui/icons-material'
interface PageData {
id: string
title: string
slug: string
content: string
contentType: 'RICH_TEXT' | 'HTML' | 'MARKDOWN'
excerpt?: string
featuredImage?: string
seoTitle?: string
seoDescription?: string
publishedAt: string
updatedAt: string
}
interface PageProps {
params: {
locale: string
slug: string
}
}
async function getPageData(slug: string): Promise<PageData | null> {
try {
const response = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3010'}/api/pages/${slug}`, {
next: { revalidate: 300 } // Revalidate every 5 minutes
})
if (!response.ok) {
return null
}
const data = await response.json()
return data.success ? data.data : null
} catch (error) {
console.error('Failed to fetch page:', error)
return null
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const page = await getPageData(params.slug)
if (!page) {
return {
title: 'Page Not Found',
description: 'The requested page could not be found.'
}
}
return {
title: page.seoTitle || page.title,
description: page.seoDescription || page.excerpt || `Read ${page.title} on Biblical Guide`,
openGraph: {
title: page.seoTitle || page.title,
description: page.seoDescription || page.excerpt,
type: 'article',
publishedTime: page.publishedAt,
modifiedTime: page.updatedAt,
...(page.featuredImage && {
images: [{
url: page.featuredImage,
alt: page.title
}]
})
},
twitter: {
card: 'summary_large_image',
title: page.seoTitle || page.title,
description: page.seoDescription || page.excerpt,
...(page.featuredImage && {
images: [page.featuredImage]
})
}
}
}
function renderContent(content: string, contentType: string) {
switch (contentType) {
case 'HTML':
return (
<Box
dangerouslySetInnerHTML={{ __html: content }}
sx={{
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: 1
},
'& p': {
marginBottom: 2
},
'& h1, & h2, & h3, & h4, & h5, & h6': {
marginTop: 3,
marginBottom: 2
},
'& ul, & ol': {
paddingLeft: 3
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'primary.main',
paddingLeft: 2,
marginLeft: 0,
fontStyle: 'italic',
backgroundColor: 'grey.50',
padding: 2,
borderRadius: 1
},
'& code': {
backgroundColor: 'grey.100',
padding: '2px 4px',
borderRadius: '4px',
fontFamily: 'monospace'
},
'& pre': {
backgroundColor: 'grey.100',
padding: 2,
borderRadius: 1,
overflow: 'auto'
}
}}
/>
)
case 'MARKDOWN':
// For now, render as plain text. In the future, you could add a markdown parser
return (
<Typography component="div" sx={{ whiteSpace: 'pre-wrap' }}>
{content}
</Typography>
)
default: // RICH_TEXT
return (
<Box
dangerouslySetInnerHTML={{ __html: content }}
sx={{
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: 1
},
'& p': {
marginBottom: 2
},
'& h1, & h2, & h3, & h4, & h5, & h6': {
marginTop: 3,
marginBottom: 2
},
'& ul, & ol': {
paddingLeft: 3
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'primary.main',
paddingLeft: 2,
marginLeft: 0,
fontStyle: 'italic',
backgroundColor: 'grey.50',
padding: 2,
borderRadius: 1
}
}}
/>
)
}
}
export default async function PageView({ params }: PageProps) {
const page = await getPageData(params.slug)
if (!page) {
notFound()
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(params.locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href={`/${params.locale}`}
>
<HomeIcon sx={{ mr: 0.5 }} fontSize="inherit" />
Home
</Link>
<Typography
color="text.primary"
sx={{ display: 'flex', alignItems: 'center' }}
>
<ArticleIcon sx={{ mr: 0.5 }} fontSize="inherit" />
{page.title}
</Typography>
</Breadcrumbs>
<Paper sx={{ p: 4 }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
{/* Featured Image */}
{page.featuredImage && (
<Box sx={{ mb: 3 }}>
<img
src={page.featuredImage}
alt={page.title}
style={{
width: '100%',
maxHeight: '400px',
objectFit: 'cover',
borderRadius: '8px'
}}
/>
</Box>
)}
{/* Title */}
<Typography
variant="h3"
component="h1"
gutterBottom
sx={{
fontWeight: 700,
color: 'text.primary',
mb: 2
}}
>
{page.title}
</Typography>
{/* Excerpt */}
{page.excerpt && (
<Typography
variant="h6"
color="text.secondary"
sx={{
fontWeight: 400,
mb: 3,
fontStyle: 'italic'
}}
>
{page.excerpt}
</Typography>
)}
{/* Meta Information */}
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
mb: 4,
pb: 2,
borderBottom: '1px solid',
borderColor: 'divider'
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<DateIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography variant="caption" color="text.secondary">
Published {formatDate(page.publishedAt)}
</Typography>
</Box>
{page.updatedAt !== page.publishedAt && (
<Typography variant="caption" color="text.secondary">
Updated {formatDate(page.updatedAt)}
</Typography>
)}
</Box>
</Box>
{/* Content */}
<Box sx={{
typography: 'body1',
lineHeight: 1.7,
'& > *:first-of-type': { marginTop: 0 },
'& > *:last-child': { marginBottom: 0 }
}}>
{renderContent(page.content, page.contentType)}
</Box>
</Paper>
</Container>
</Box>
)
}

357
app/admin/pages/page.tsx Normal file
View File

@@ -0,0 +1,357 @@
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip,
IconButton,
Breadcrumbs,
Link,
Alert
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Visibility as ViewIcon,
Home as HomeIcon,
ArticleOutlined as PagesIcon,
Public as PublicIcon,
Navigation as NavigationIcon,
Foundation as FooterIcon
} from '@mui/icons-material';
import { DataGrid, GridColDef, GridActionsCellItem } from '@mui/x-data-grid';
import { PageEditor } from '@/components/admin/pages/page-editor';
interface Page {
id: string;
title: string;
slug: string;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
showInNavigation: boolean;
showInFooter: boolean;
createdAt: string;
updatedAt: string;
creator: { name: string; email: string };
updater: { name: string; email: string };
}
export default function PagesManagement() {
const [pages, setPages] = useState<Page[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPage, setSelectedPage] = useState<Page | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pageToDelete, setPageToDelete] = useState<Page | null>(null);
const [filterStatus, setFilterStatus] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
fetchPages();
}, [filterStatus, searchQuery]);
const fetchPages = async () => {
try {
const token = localStorage.getItem('authToken');
const params = new URLSearchParams();
if (filterStatus !== 'all') params.append('status', filterStatus);
if (searchQuery) params.append('search', searchQuery);
const response = await fetch(`/api/admin/pages?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch pages');
}
const data = await response.json();
setPages(data.data || []);
} catch (error) {
console.error('Error fetching pages:', error);
setError('Failed to load pages');
} finally {
setLoading(false);
}
};
const handleCreatePage = () => {
setSelectedPage(null);
setEditorOpen(true);
};
const handleEditPage = (page: Page) => {
setSelectedPage(page);
setEditorOpen(true);
};
const handleDeletePage = async (page: Page) => {
setPageToDelete(page);
setDeleteDialogOpen(true);
};
const confirmDeletePage = async () => {
if (!pageToDelete) return;
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`/api/admin/pages/${pageToDelete.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to delete page');
}
setSuccess('Page deleted successfully');
fetchPages();
setDeleteDialogOpen(false);
setPageToDelete(null);
} catch (error) {
console.error('Error deleting page:', error);
setError('Failed to delete page');
}
};
const handlePageSaved = () => {
setSuccess('Page saved successfully');
setEditorOpen(false);
fetchPages();
};
const getStatusColor = (status: string) => {
switch (status) {
case 'PUBLISHED': return 'success';
case 'DRAFT': return 'warning';
case 'ARCHIVED': return 'default';
default: return 'default';
}
};
const columns: GridColDef[] = [
{
field: 'title',
headerName: 'Title',
width: 300,
renderCell: (params) => (
<Box>
<Typography variant="body2" fontWeight="medium">
{params.value}
</Typography>
<Typography variant="caption" color="text.secondary">
/{params.row.slug}
</Typography>
</Box>
)
},
{
field: 'status',
headerName: 'Status',
width: 120,
renderCell: (params) => (
<Chip
label={params.value}
color={getStatusColor(params.value) as any}
size="small"
/>
)
},
{
field: 'showInNavigation',
headerName: 'Navigation',
width: 100,
renderCell: (params) => (
params.value ? <NavigationIcon color="primary" /> : null
)
},
{
field: 'showInFooter',
headerName: 'Footer',
width: 80,
renderCell: (params) => (
params.value ? <FooterIcon color="primary" /> : null
)
},
{
field: 'creator',
headerName: 'Created By',
width: 150,
renderCell: (params) => params.value?.name || params.value?.email
},
{
field: 'updatedAt',
headerName: 'Last Updated',
width: 150,
renderCell: (params) => new Date(params.value).toLocaleDateString()
},
{
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 120,
getActions: (params) => [
<GridActionsCellItem
key="edit"
icon={<EditIcon />}
label="Edit"
onClick={() => handleEditPage(params.row)}
/>,
<GridActionsCellItem
key="view"
icon={<ViewIcon />}
label="View"
onClick={() => window.open(`/pages/${params.row.slug}`, '_blank')}
/>,
<GridActionsCellItem
key="delete"
icon={<DeleteIcon />}
label="Delete"
onClick={() => handleDeletePage(params.row)}
/>
]
}
];
return (
<Box>
{/* Breadcrumbs */}
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
<Link
underline="hover"
sx={{ display: 'flex', alignItems: 'center' }}
color="inherit"
href="/admin"
>
<HomeIcon sx={{ mr: 0.5 }} fontSize="inherit" />
Admin
</Link>
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
<PagesIcon sx={{ mr: 0.5 }} fontSize="inherit" />
Pages
</Typography>
</Breadcrumbs>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Page Management
</Typography>
<Typography variant="body1" color="text.secondary">
Create and manage website pages for navigation and footer
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleCreatePage}
sx={{ height: 'fit-content' }}
>
New Page
</Button>
</Box>
{/* Alerts */}
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" onClose={() => setSuccess(null)} sx={{ mb: 2 }}>
{success}
</Alert>
)}
{/* Filters */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Search pages"
variant="outlined"
size="small"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
sx={{ minWidth: 300 }}
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Status</InputLabel>
<Select
value={filterStatus}
label="Status"
onChange={(e) => setFilterStatus(e.target.value)}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="published">Published</MenuItem>
<MenuItem value="draft">Draft</MenuItem>
<MenuItem value="archived">Archived</MenuItem>
</Select>
</FormControl>
</Box>
</CardContent>
</Card>
{/* Pages Table */}
<Card>
<DataGrid
rows={pages}
columns={columns}
loading={loading}
autoHeight
pageSize={25}
disableSelectionOnClick
sx={{
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0'
}
}}
/>
</Card>
{/* Page Editor Dialog */}
<PageEditor
open={editorOpen}
onClose={() => setEditorOpen(false)}
page={selectedPage}
onSave={handlePageSaved}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete Page</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={confirmDeletePage} color="error" variant="contained">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { prisma } from '@/lib/db';
import { verifyAdminAuth } from '@/lib/admin-auth';
const UPLOAD_DIR = path.join(process.cwd(), 'public', 'uploads');
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
export async function POST(request: NextRequest) {
try {
const adminUser = await verifyAdminAuth(request);
if (!adminUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
const alt = formData.get('alt') as string;
if (!file) {
return NextResponse.json(
{ success: false, error: 'No file provided' },
{ status: 400 }
);
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ success: false, error: 'Invalid file type. Only images are allowed.' },
{ status: 400 }
);
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ success: false, error: 'File too large. Maximum size is 5MB.' },
{ status: 400 }
);
}
// Ensure upload directory exists
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
// Generate unique filename
const timestamp = Date.now();
const extension = path.extname(file.name);
const baseFilename = file.name.replace(extension, '').replace(/[^a-zA-Z0-9]/g, '-');
const filename = `${timestamp}-${baseFilename}${extension}`;
const filePath = path.join(UPLOAD_DIR, filename);
// Write file to disk
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filePath, buffer);
// Save to database
const mediaFile = await prisma.mediaFile.create({
data: {
filename,
originalName: file.name,
mimeType: file.type,
size: file.size,
path: filePath,
url: `/uploads/${filename}`,
alt: alt || null,
uploadedBy: adminUser.id
}
});
return NextResponse.json({
success: true,
data: mediaFile
});
} catch (error) {
console.error('Error uploading file:', error);
return NextResponse.json(
{ success: false, error: 'Failed to upload file' },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
try {
const adminUser = await verifyAdminAuth(request);
if (!adminUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const type = searchParams.get('type');
const skip = (page - 1) * limit;
const where: any = {};
if (type) {
where.mimeType = { startsWith: type };
}
const [files, total] = await Promise.all([
prisma.mediaFile.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
uploader: { select: { name: true, email: true } }
}
}),
prisma.mediaFile.count({ where })
]);
return NextResponse.json({
success: true,
data: files,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Error fetching media files:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch media files' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,174 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminAuth } from '@/lib/admin-auth';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const adminUser = await verifyAdminAuth(request);
if (!adminUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const page = await prisma.page.findUnique({
where: { id: params.id },
include: {
creator: { select: { name: true, email: true } },
updater: { select: { name: true, email: true } }
}
});
if (!page) {
return NextResponse.json(
{ success: false, error: 'Page not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: page
});
} catch (error) {
console.error('Error fetching page:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch page' },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const adminUser = await verifyAdminAuth(request);
if (!adminUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const {
title,
slug,
content,
contentType,
excerpt,
featuredImage,
seoTitle,
seoDescription,
status,
showInNavigation,
showInFooter,
navigationOrder,
footerOrder
} = body;
// Check if page exists
const existingPage = await prisma.page.findUnique({
where: { id: params.id }
});
if (!existingPage) {
return NextResponse.json(
{ success: false, error: 'Page not found' },
{ status: 404 }
);
}
// Check if slug is being changed and conflicts with another page
if (slug && slug !== existingPage.slug) {
const conflictingPage = await prisma.page.findUnique({
where: { slug }
});
if (conflictingPage && conflictingPage.id !== params.id) {
return NextResponse.json(
{ success: false, error: 'A page with this slug already exists' },
{ status: 400 }
);
}
}
const updatedPage = await prisma.page.update({
where: { id: params.id },
data: {
title,
slug,
content,
contentType,
excerpt,
featuredImage,
seoTitle,
seoDescription,
status,
showInNavigation,
showInFooter,
navigationOrder,
footerOrder,
updatedBy: adminUser.id,
publishedAt: status === 'PUBLISHED' && !existingPage.publishedAt
? new Date()
: status === 'PUBLISHED'
? existingPage.publishedAt
: null
},
include: {
creator: { select: { name: true, email: true } },
updater: { select: { name: true, email: true } }
}
});
return NextResponse.json({
success: true,
data: updatedPage
});
} catch (error) {
console.error('Error updating page:', error);
return NextResponse.json(
{ success: false, error: 'Failed to update page' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const adminUser = await verifyAdminAuth(request);
if (!adminUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const page = await prisma.page.findUnique({
where: { id: params.id }
});
if (!page) {
return NextResponse.json(
{ success: false, error: 'Page not found' },
{ status: 404 }
);
}
await prisma.page.delete({
where: { id: params.id }
});
return NextResponse.json({
success: true,
message: 'Page deleted successfully'
});
} catch (error) {
console.error('Error deleting page:', error);
return NextResponse.json(
{ success: false, error: 'Failed to delete page' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminAuth } from '@/lib/admin-auth';
export async function GET(request: NextRequest) {
try {
const adminUser = await verifyAdminAuth(request);
if (!adminUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const status = searchParams.get('status');
const search = searchParams.get('search');
const skip = (page - 1) * limit;
const where: any = {};
if (status && status !== 'all') {
where.status = status.toUpperCase();
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } }
];
}
const [pages, total] = await Promise.all([
prisma.page.findMany({
where,
orderBy: { updatedAt: 'desc' },
skip,
take: limit,
include: {
creator: { select: { name: true, email: true } },
updater: { select: { name: true, email: true } }
}
}),
prisma.page.count({ where })
]);
return NextResponse.json({
success: true,
data: pages,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('Error fetching pages:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch pages' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const adminUser = await verifyAdminAuth(request);
if (!adminUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const {
title,
slug,
content,
contentType = 'RICH_TEXT',
excerpt,
featuredImage,
seoTitle,
seoDescription,
status = 'DRAFT',
showInNavigation = false,
showInFooter = false,
navigationOrder,
footerOrder
} = body;
if (!title || !slug || !content) {
return NextResponse.json(
{ success: false, error: 'Title, slug, and content are required' },
{ status: 400 }
);
}
// Check if slug already exists
const existingPage = await prisma.page.findUnique({
where: { slug }
});
if (existingPage) {
return NextResponse.json(
{ success: false, error: 'A page with this slug already exists' },
{ status: 400 }
);
}
const page = await prisma.page.create({
data: {
title,
slug,
content,
contentType,
excerpt,
featuredImage,
seoTitle,
seoDescription,
status,
showInNavigation,
showInFooter,
navigationOrder,
footerOrder,
createdBy: adminUser.id,
updatedBy: adminUser.id,
publishedAt: status === 'PUBLISHED' ? new Date() : null
},
include: {
creator: { select: { name: true, email: true } },
updater: { select: { name: true, email: true } }
}
});
return NextResponse.json({
success: true,
data: page
});
} catch (error) {
console.error('Error creating page:', error);
return NextResponse.json(
{ success: false, error: 'Failed to create page' },
{ status: 500 }
);
}
}

View File

@@ -9,17 +9,17 @@ export async function GET(request: Request) {
console.log('Books API called')
const { searchParams } = new URL(request.url)
const locale = searchParams.get('locale') || 'ro'
const versionAbbr = searchParams.get('version') // Optional specific version
console.log('Locale:', locale, 'Version:', versionAbbr)
const versionId = searchParams.get('version') // Optional specific version ID
console.log('Locale:', locale, 'Version ID:', versionId)
// Get the appropriate Bible version
let bibleVersion
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
if (versionAbbr) {
if (versionId) {
// Use specific version if provided
bibleVersion = await prisma.bibleVersion.findFirst({
where: {
abbreviation: versionAbbr,
id: versionId,
language: { in: langCandidates }
}
})

View File

@@ -9,9 +9,10 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const bookId = searchParams.get('book') || ''
const chapterNum = parseInt(searchParams.get('chapter') || '1')
const versionId = searchParams.get('version') || ''
// Check cache first
const cacheKey = CacheManager.getChapterKey(bookId, chapterNum)
// Check cache first (include version in cache key)
const cacheKey = CacheManager.getChapterKey(bookId, chapterNum, versionId)
const cachedChapter = await CacheManager.get(cacheKey)
if (cachedChapter) {
@@ -25,7 +26,8 @@ export async function GET(request: Request) {
const chapter = await prisma.bibleChapter.findFirst({
where: {
bookId,
chapterNum
chapterNum,
book: versionId ? { versionId } : undefined
},
include: {
verses: {
@@ -33,7 +35,11 @@ export async function GET(request: Request) {
verseNum: 'asc'
}
},
book: true
book: {
include: {
version: true
}
}
}
})

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const page = await prisma.page.findUnique({
where: {
slug: params.slug,
status: 'PUBLISHED'
},
select: {
id: true,
title: true,
slug: true,
content: true,
contentType: true,
excerpt: true,
featuredImage: true,
seoTitle: true,
seoDescription: true,
publishedAt: true,
updatedAt: true
}
});
if (!page) {
return NextResponse.json(
{ success: false, error: 'Page not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: page
});
} catch (error) {
console.error('Error fetching page by slug:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch page' },
{ status: 500 }
);
}
}

53
app/api/pages/route.ts Normal file
View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const location = searchParams.get('location'); // 'navigation', 'footer', or 'all'
const where: any = {
status: 'PUBLISHED'
};
if (location === 'navigation') {
where.showInNavigation = true;
} else if (location === 'footer') {
where.showInFooter = true;
}
const orderBy: any = [];
if (location === 'navigation') {
orderBy.push({ navigationOrder: 'asc' });
} else if (location === 'footer') {
orderBy.push({ footerOrder: 'asc' });
}
orderBy.push({ title: 'asc' });
const pages = await prisma.page.findMany({
where,
orderBy,
select: {
id: true,
title: true,
slug: true,
excerpt: true,
showInNavigation: true,
showInFooter: true,
navigationOrder: true,
footerOrder: true
}
});
return NextResponse.json({
success: true,
data: pages
});
} catch (error) {
console.error('Error fetching public pages:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch pages' },
{ status: 500 }
);
}
}

View File

@@ -31,7 +31,8 @@ import {
Logout,
AccountCircle,
AdminPanelSettings,
Launch as LaunchIcon
Launch as LaunchIcon,
Article as PageIcon
} from '@mui/icons-material';
interface AdminLayoutProps {
@@ -49,6 +50,7 @@ const drawerWidth = 280;
const menuItems = [
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
{ text: 'Users', icon: People, href: '/admin/users' },
{ text: 'Pages', icon: PageIcon, href: '/admin/pages' },
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },

View File

@@ -0,0 +1,329 @@
'use client';
import { useState, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
TextField,
Paper,
Grid,
Card,
CardMedia,
CardContent,
CardActions,
Alert,
LinearProgress,
Tabs,
Tab
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Image as ImageIcon,
InsertPhoto as InsertIcon
} from '@mui/icons-material';
interface MediaFile {
id: string;
filename: string;
originalName: string;
url: string;
alt: string | null;
size: number;
createdAt: string;
}
interface ImageUploadProps {
open: boolean;
onClose: () => void;
onImageSelect: (imageUrl: string) => void;
}
export function ImageUpload({ open, onClose, onImageSelect }: ImageUploadProps) {
const [tab, setTab] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
const [loadingMedia, setLoadingMedia] = useState(false);
const [urlInput, setUrlInput] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchMediaFiles = async () => {
setLoadingMedia(true);
try {
const token = localStorage.getItem('authToken');
const response = await fetch('/api/admin/media?type=image', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch media files');
}
const data = await response.json();
setMediaFiles(data.data || []);
} catch (error) {
console.error('Error fetching media files:', error);
setError('Failed to load media files');
} finally {
setLoadingMedia(false);
}
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
// Validate file type
if (!file.type.startsWith('image/')) {
setError('Please select an image file');
return;
}
// Validate file size (5MB limit)
if (file.size > 5 * 1024 * 1024) {
setError('File size must be less than 5MB');
return;
}
setUploading(true);
setError(null);
setUploadProgress(0);
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('authToken');
const response = await fetch('/api/admin/media', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Upload failed');
}
const result = await response.json();
// Add the new file to the media files list
setMediaFiles(prev => [result.data, ...prev]);
// Auto-select the uploaded image
onImageSelect(result.data.url);
setUploadProgress(100);
// Reset form
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error) {
console.error('Error uploading file:', error);
setError(error instanceof Error ? error.message : 'Upload failed');
} finally {
setUploading(false);
setTimeout(() => setUploadProgress(0), 1000);
}
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue);
if (newValue === 1 && mediaFiles.length === 0) {
fetchMediaFiles();
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleUrlSubmit = () => {
if (urlInput.trim()) {
onImageSelect(urlInput.trim());
setUrlInput('');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Insert Image</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tab} onChange={handleTabChange}>
<Tab label="Upload New" />
<Tab label="Media Library" />
<Tab label="From URL" />
</Tabs>
</Box>
{/* Upload Tab */}
{tab === 0 && (
<Box>
<Paper
sx={{
p: 4,
textAlign: 'center',
border: '2px dashed #ccc',
bgcolor: 'grey.50',
cursor: 'pointer',
'&:hover': {
bgcolor: 'grey.100'
}
}}
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon sx={{ fontSize: 48, color: 'grey.500', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Click to upload or drag and drop
</Typography>
<Typography color="text.secondary">
PNG, JPG, GIF up to 5MB
</Typography>
<input
type="file"
ref={fileInputRef}
onChange={(e) => handleFileUpload(e.target.files)}
accept="image/*"
style={{ display: 'none' }}
/>
</Paper>
{uploading && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" gutterBottom>
Uploading...
</Typography>
<LinearProgress variant="determinate" value={uploadProgress} />
</Box>
)}
</Box>
)}
{/* Media Library Tab */}
{tab === 1 && (
<Box>
{loadingMedia ? (
<Typography>Loading media files...</Typography>
) : (
<Grid container spacing={2}>
{mediaFiles.length === 0 ? (
<Grid item xs={12}>
<Typography color="text.secondary" textAlign="center">
No images found. Upload some images first.
</Typography>
</Grid>
) : (
mediaFiles.map((file) => (
<Grid item xs={12} sm={6} md={4} key={file.id}>
<Card>
<CardMedia
component="img"
height="140"
image={file.url}
alt={file.alt || file.originalName}
sx={{ objectFit: 'cover' }}
/>
<CardContent sx={{ p: 1 }}>
<Typography variant="caption" display="block" noWrap>
{file.originalName}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatFileSize(file.size)}
</Typography>
</CardContent>
<CardActions sx={{ pt: 0 }}>
<Button
size="small"
startIcon={<InsertIcon />}
onClick={() => onImageSelect(file.url)}
>
Insert
</Button>
</CardActions>
</Card>
</Grid>
))
)}
</Grid>
)}
</Box>
)}
{/* URL Tab */}
{tab === 2 && (
<Box>
<TextField
fullWidth
label="Image URL"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder="https://example.com/image.jpg"
sx={{ mb: 2 }}
/>
<Box sx={{ textAlign: 'center' }}>
<Button
variant="contained"
onClick={handleUrlSubmit}
disabled={!urlInput.trim()}
>
Insert Image
</Button>
</Box>
{urlInput && (
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="subtitle2" gutterBottom>
Preview:
</Typography>
<img
src={urlInput}
alt="Preview"
style={{
maxWidth: '100%',
maxHeight: '200px',
border: '1px solid #ccc',
borderRadius: '4px'
}}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</Box>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,471 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Box,
Typography,
Tab,
Tabs,
Alert,
Grid,
Paper
} from '@mui/material';
import { Editor } from '@tinymce/tinymce-react';
import { ImageUpload } from './image-upload';
interface Page {
id?: string;
title: string;
slug: string;
content: string;
contentType: 'RICH_TEXT' | 'HTML' | 'MARKDOWN';
excerpt?: string;
featuredImage?: string;
seoTitle?: string;
seoDescription?: string;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
showInNavigation: boolean;
showInFooter: boolean;
navigationOrder?: number;
footerOrder?: number;
}
interface PageEditorProps {
open: boolean;
onClose: () => void;
page?: Page | null;
onSave: () => void;
}
export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
const [formData, setFormData] = useState<Page>({
title: '',
slug: '',
content: '',
contentType: 'RICH_TEXT',
excerpt: '',
featuredImage: '',
seoTitle: '',
seoDescription: '',
status: 'DRAFT',
showInNavigation: false,
showInFooter: false,
navigationOrder: undefined,
footerOrder: undefined
});
const [contentTab, setContentTab] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imageUploadOpen, setImageUploadOpen] = useState(false);
const editorRef = useRef<any>(null);
useEffect(() => {
if (page) {
setFormData({
...page,
excerpt: page.excerpt || '',
featuredImage: page.featuredImage || '',
seoTitle: page.seoTitle || '',
seoDescription: page.seoDescription || ''
});
} else {
setFormData({
title: '',
slug: '',
content: '',
contentType: 'RICH_TEXT',
excerpt: '',
featuredImage: '',
seoTitle: '',
seoDescription: '',
status: 'DRAFT',
showInNavigation: false,
showInFooter: false,
navigationOrder: undefined,
footerOrder: undefined
});
}
setError(null);
}, [page, open]);
const generateSlug = (title: string) => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleTitleChange = (title: string) => {
setFormData(prev => ({
...prev,
title,
slug: prev.slug || generateSlug(title)
}));
};
const handleSubmit = async () => {
if (!formData.title || !formData.slug || !formData.content) {
setError('Title, slug, and content are required');
return;
}
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('authToken');
const url = page ? `/api/admin/pages/${page.id}` : '/api/admin/pages';
const method = page ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save page');
}
onSave();
} catch (error) {
console.error('Error saving page:', error);
setError(error instanceof Error ? error.message : 'Failed to save page');
} finally {
setLoading(false);
}
};
const handleImageInsert = (imageUrl: string) => {
if (formData.contentType === 'RICH_TEXT' && editorRef.current) {
const editor = editorRef.current.getEditor();
editor.insertContent(`<img src="${imageUrl}" alt="" style="max-width: 100%; height: auto;" />`);
} else if (formData.contentType === 'HTML') {
const imageTag = `<img src="${imageUrl}" alt="" style="max-width: 100%; height: auto;" />`;
setFormData(prev => ({
...prev,
content: prev.content + imageTag
}));
}
setImageUploadOpen(false);
};
const renderContentEditor = () => {
switch (formData.contentType) {
case 'RICH_TEXT':
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">Content</Typography>
<Button size="small" onClick={() => setImageUploadOpen(true)}>
Insert Image
</Button>
</Box>
<Editor
onInit={(evt, editor) => editorRef.current = { getEditor: () => editor }}
value={formData.content}
onEditorChange={(content) => setFormData(prev => ({ ...prev, content }))}
init={{
height: 400,
menubar: true,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | link image | code | help',
content_style: 'body { font-family: Arial, Helvetica, sans-serif; font-size: 14px }',
images_upload_handler: (blobInfo: any) => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
const token = localStorage.getItem('authToken');
fetch('/api/admin/media', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
.then(response => response.json())
.then(result => {
if (result.success) {
resolve(result.data.url);
} else {
reject(result.error || 'Upload failed');
}
})
.catch(error => reject(error));
});
}
}}
/>
</Box>
);
case 'HTML':
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">HTML Content</Typography>
<Button size="small" onClick={() => setImageUploadOpen(true)}>
Insert Image
</Button>
</Box>
<TextField
multiline
rows={20}
fullWidth
variant="outlined"
value={formData.content}
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
sx={{ fontFamily: 'monospace' }}
/>
</Box>
);
case 'MARKDOWN':
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Markdown Content</Typography>
<TextField
multiline
rows={20}
fullWidth
variant="outlined"
value={formData.content}
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
sx={{ fontFamily: 'monospace' }}
/>
</Box>
);
default:
return null;
}
};
return (
<>
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
{page ? 'Edit Page' : 'Create New Page'}
</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={contentTab} onChange={(_, newValue) => setContentTab(newValue)}>
<Tab label="Content" />
<Tab label="Settings" />
<Tab label="SEO" />
</Tabs>
</Box>
{/* Content Tab */}
{contentTab === 0 && (
<Box>
<Grid container spacing={2}>
<Grid item xs={12} sm={8}>
<TextField
label="Page Title"
fullWidth
required
value={formData.title}
onChange={(e) => handleTitleChange(e.target.value)}
/>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel>Content Type</InputLabel>
<Select
value={formData.contentType}
label="Content Type"
onChange={(e) => setFormData(prev => ({ ...prev, contentType: e.target.value as any }))}
>
<MenuItem value="RICH_TEXT">Rich Text</MenuItem>
<MenuItem value="HTML">HTML</MenuItem>
<MenuItem value="MARKDOWN">Markdown</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<TextField
label="URL Slug"
fullWidth
required
value={formData.slug}
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value }))}
helperText="This will be the URL path for your page"
sx={{ mt: 2 }}
/>
<TextField
label="Excerpt"
fullWidth
multiline
rows={2}
value={formData.excerpt}
onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
helperText="Brief description (optional)"
sx={{ mt: 2 }}
/>
{renderContentEditor()}
</Box>
)}
{/* Settings Tab */}
{contentTab === 1 && (
<Box>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Publication</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Status</InputLabel>
<Select
value={formData.status}
label="Status"
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as any }))}
>
<MenuItem value="DRAFT">Draft</MenuItem>
<MenuItem value="PUBLISHED">Published</MenuItem>
<MenuItem value="ARCHIVED">Archived</MenuItem>
</Select>
</FormControl>
<TextField
label="Featured Image URL"
fullWidth
value={formData.featuredImage}
onChange={(e) => setFormData(prev => ({ ...prev, featuredImage: e.target.value }))}
/>
</Paper>
</Grid>
<Grid item xs={12} sm={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>Display Options</Typography>
<FormControlLabel
control={
<Switch
checked={formData.showInNavigation}
onChange={(e) => setFormData(prev => ({ ...prev, showInNavigation: e.target.checked }))}
/>
}
label="Show in Navigation"
/>
{formData.showInNavigation && (
<TextField
label="Navigation Order"
type="number"
fullWidth
size="small"
value={formData.navigationOrder || ''}
onChange={(e) => setFormData(prev => ({ ...prev, navigationOrder: parseInt(e.target.value) || undefined }))}
sx={{ mt: 1, mb: 2 }}
/>
)}
<FormControlLabel
control={
<Switch
checked={formData.showInFooter}
onChange={(e) => setFormData(prev => ({ ...prev, showInFooter: e.target.checked }))}
/>
}
label="Show in Footer"
/>
{formData.showInFooter && (
<TextField
label="Footer Order"
type="number"
fullWidth
size="small"
value={formData.footerOrder || ''}
onChange={(e) => setFormData(prev => ({ ...prev, footerOrder: parseInt(e.target.value) || undefined }))}
sx={{ mt: 1 }}
/>
)}
</Paper>
</Grid>
</Grid>
</Box>
)}
{/* SEO Tab */}
{contentTab === 2 && (
<Box>
<TextField
label="SEO Title"
fullWidth
value={formData.seoTitle}
onChange={(e) => setFormData(prev => ({ ...prev, seoTitle: e.target.value }))}
helperText="Optimize for search engines (leave empty to use page title)"
sx={{ mb: 2 }}
/>
<TextField
label="SEO Description"
fullWidth
multiline
rows={3}
value={formData.seoDescription}
onChange={(e) => setFormData(prev => ({ ...prev, seoDescription: e.target.value }))}
helperText="Meta description for search engines (150-160 characters recommended)"
/>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSubmit} variant="contained" disabled={loading}>
{loading ? 'Saving...' : page ? 'Update Page' : 'Create Page'}
</Button>
</DialogActions>
</Dialog>
<ImageUpload
open={imageUploadOpen}
onClose={() => setImageUploadOpen(false)}
onImageSelect={handleImageInsert}
/>
</>
);
}

View File

@@ -0,0 +1,167 @@
'use client'
import { useState, useEffect } from 'react'
import {
Paper,
Container,
Box,
Typography,
Button,
Divider,
IconButton,
Chip,
} from '@mui/material'
import {
Facebook,
Twitter,
Instagram,
YouTube,
} from '@mui/icons-material'
import { useRouter } from 'next/navigation'
import { useTranslations, useLocale } from 'next-intl'
interface DynamicPage {
id: string
title: string
slug: string
showInFooter: boolean
footerOrder?: number
}
export function Footer() {
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
const router = useRouter()
const t = useTranslations('home')
const tSeo = useTranslations('seo')
const locale = useLocale()
useEffect(() => {
fetchDynamicPages()
}, [])
const fetchDynamicPages = async () => {
try {
const response = await fetch('/api/pages?location=footer')
if (response.ok) {
const data = await response.json()
setDynamicPages(data.data || [])
}
} catch (error) {
console.error('Failed to fetch dynamic pages:', error)
}
}
const getCurrentYear = () => {
return new Date().getFullYear()
}
return (
<Paper component="footer" sx={{ bgcolor: 'grey.900', color: 'white', py: 6 }}>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'space-between', mb: 4 }}>
{/* Brand */}
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
{t('footer.brand')}
</Typography>
<Typography variant="body2" color="grey.400" sx={{ maxWidth: 300 }}>
{tSeo('footer')}
</Typography>
</Box>
{/* Quick Links */}
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
<Typography variant="h6" sx={{ mb: 2 }}>
{t('footer.quickLinks.title')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.about')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.blog')}
</Button>
<Button
color="inherit"
sx={{ justifyContent: 'flex-start', p: 0 }}
onClick={() => router.push(`/${locale}/contact`)}
>
{t('footer.quickLinks.contact')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.support')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.quickLinks.api')}
</Button>
{dynamicPages.map((page) => (
<Button
key={page.id}
color="inherit"
sx={{ justifyContent: 'flex-start', p: 0 }}
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
>
{page.title}
</Button>
))}
</Box>
</Box>
{/* Legal */}
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
<Typography variant="h6" sx={{ mb: 2 }}>
{t('footer.legal.title')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.terms')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.privacy')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.cookies')}
</Button>
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
{t('footer.legal.gdpr')}
</Button>
</Box>
</Box>
{/* Social */}
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
<Typography variant="h6" sx={{ mb: 2 }}>
{t('footer.social.title')}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton color="inherit" size="small">
<Facebook />
</IconButton>
<IconButton color="inherit" size="small">
<Twitter />
</IconButton>
<IconButton color="inherit" size="small">
<Instagram />
</IconButton>
<IconButton color="inherit" size="small">
<YouTube />
</IconButton>
</Box>
</Box>
</Box>
<Divider sx={{ bgcolor: 'grey.700', mb: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="body2" color="grey.400">
© {getCurrentYear()} Biblical Guide - {locale === 'ro' ? 'Făcut cu ❤️ și 🙏' : 'Made with ❤️ and 🙏'}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label="🇷🇴 Română" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
<Chip label="🇺🇸 English" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
<Chip label="+20 more" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
</Box>
</Box>
</Container>
</Paper>
)
}

View File

@@ -1,5 +1,5 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import {
AppBar,
Box,
@@ -38,10 +38,19 @@ import { useTranslations, useLocale } from 'next-intl'
import { LanguageSwitcher } from './language-switcher'
import { useAuth } from '@/hooks/use-auth'
interface DynamicPage {
id: string
title: string
slug: string
showInNavigation: boolean
navigationOrder?: number
}
export function Navigation() {
const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null)
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
const router = useRouter()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
@@ -49,13 +58,37 @@ export function Navigation() {
const locale = useLocale()
const { user, isAuthenticated, logout } = useAuth()
const pages = [
useEffect(() => {
fetchDynamicPages()
}, [])
const fetchDynamicPages = async () => {
try {
const response = await fetch('/api/pages?location=navigation')
if (response.ok) {
const data = await response.json()
setDynamicPages(data.data || [])
}
} catch (error) {
console.error('Failed to fetch dynamic pages:', error)
}
}
const basePages = [
{ name: t('home'), path: '/', icon: <Home /> },
{ name: t('bible'), path: '/bible', icon: <MenuBook /> },
{ name: t('prayers'), path: '/prayers', icon: <Prayer /> },
{ name: t('search'), path: '/search', icon: <Search /> },
]
const dynamicNavPages = dynamicPages.map(page => ({
name: page.title,
path: `/pages/${page.slug}`,
icon: null
}))
const pages = [...basePages, ...dynamicNavPages]
const authenticatedPages = [
...pages,
{ name: t('bookmarks'), path: '/bookmarks', icon: <Bookmark /> },

View File

@@ -86,7 +86,7 @@ export function Navigation() {
onClick={() => router.push('/')}
className="text-xl font-bold text-blue-600 hover:text-blue-700 transition-colors"
>
Ghid Biblic
Biblical Guide
</button>
<div className="hidden md:flex space-x-4">

View File

@@ -1,52 +1,41 @@
import { User } from '@prisma/client';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { verify } from 'jsonwebtoken';
import { prisma } from '@/lib/db';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-key';
export interface AdminUser {
id: string;
email: string;
name: string | null;
role: string;
permissions: string[];
}
export enum AdminPermission {
VIEW_USERS = 'users:read',
MANAGE_USERS = 'users:write',
MODERATE_CONTENT = 'content:moderate',
VIEW_ANALYTICS = 'analytics:read',
MANAGE_SYSTEM = 'system:manage'
}
export function hasPermission(user: AdminUser, permission: AdminPermission): boolean {
if (user.role === 'admin') return true; // Super admin has all permissions
return user.permissions.includes(permission);
}
export function getAdminPermissions(role: string): AdminPermission[] {
switch (role) {
case 'admin':
return Object.values(AdminPermission); // All permissions
case 'moderator':
return [
AdminPermission.VIEW_USERS,
AdminPermission.MODERATE_CONTENT,
AdminPermission.VIEW_ANALYTICS
];
default:
return [];
}
}
export async function verifyAdminToken(token: string): Promise<AdminUser | null> {
export async function verifyAdminAuth(request: NextRequest): Promise<AdminUser | null> {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
if (!decoded.userId) return null;
const token = authHeader.substring(7);
let payload: any;
try {
payload = verify(token, JWT_SECRET);
} catch (error) {
return null;
}
if (!payload.userId) {
return null;
}
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
where: {
id: payload.userId,
role: { in: ['admin', 'moderator'] }
},
select: {
id: true,
email: true,
@@ -55,43 +44,21 @@ export async function verifyAdminToken(token: string): Promise<AdminUser | null>
}
});
if (!user || !['admin', 'moderator'].includes(user.role)) {
if (!user) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
permissions: getAdminPermissions(user.role)
};
return user;
} catch (error) {
console.error('Error verifying admin auth:', error);
return null;
}
}
export async function getCurrentAdmin(): Promise<AdminUser | null> {
const cookieStore = await cookies();
const token = cookieStore.get('adminToken')?.value;
if (!token) return null;
return verifyAdminToken(token);
export function hasAdminAccess(user: AdminUser | null): boolean {
return user?.role === 'admin' || user?.role === 'moderator';
}
export function generateAdminToken(user: User): string {
if (!['admin', 'moderator'].includes(user.role)) {
throw new Error('User is not an admin');
}
const payload = {
userId: user.id,
role: user.role,
type: 'admin'
};
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: '8h' // Admin sessions expire after 8 hours
});
export function isSuperAdmin(user: AdminUser | null): boolean {
return user?.role === 'admin';
}

4
lib/cache/index.ts vendored
View File

@@ -57,8 +57,8 @@ export class CacheManager {
}
// Helper methods for specific cache patterns
static getChapterKey(bookId: string, chapterNum: number): string {
return `chapter:${bookId}:${chapterNum}`
static getChapterKey(bookId: string, chapterNum: number, versionId?: string): string {
return versionId ? `chapter:${bookId}:${chapterNum}:${versionId}` : `chapter:${bookId}:${chapterNum}`
}
static getSearchKey(query: string, limit: number): string {

View File

@@ -179,6 +179,7 @@
"title": "Quick Links",
"about": "About",
"blog": "Blog",
"contact": "Contact",
"support": "Support",
"api": "API Docs"
},
@@ -201,6 +202,7 @@
"subtitle": "Explore Scripture with a modern and intuitive interface",
"selectBook": "Select book",
"selectChapter": "Select chapter",
"version": "Version",
"book": "Book",
"chapter": "Chapter",
"verse": "Verse",
@@ -482,6 +484,44 @@
"next": "Next",
"previous": "Previous"
},
"contact": {
"hero": {
"title": "Contact Us",
"subtitle": "Get in touch with our team",
"description": "Have questions about Biblical Guide? Need technical support? Want to share feedback? We're here to help you on your spiritual journey."
},
"form": {
"title": "Send us a message",
"description": "Fill out the form below and we'll get back to you as soon as possible.",
"fields": {
"name": "Your Name",
"email": "Email Address",
"subject": "Subject",
"message": "Your Message"
},
"submit": "Send Message",
"submitting": "Sending...",
"success": "Thank you for your message! We'll get back to you soon.",
"error": "There was an error sending your message. Please try again."
},
"info": {
"title": "Contact Information",
"description": "Reach out to us through any of these channels:",
"email": {
"title": "Email Support",
"content": "contact@biblical-guide.com"
},
"address": {
"title": "Our Office",
"content": "123 Bible Street, Faith City, FC 12345"
}
},
"faq": {
"title": "Frequently Asked Questions",
"description": "Find quick answers to common questions about Biblical Guide.",
"viewFaq": "View FAQ"
}
},
"seo": {
"title": "Biblical Guide Online Bible Study with AI, Daily Verses & Prayer Community",
"description": "Biblical Guide is an online Bible study app with AI-powered chat, instant verse search, and a global prayer community. Get daily Bible verses and Scripture-based answers to your questions.",

View File

@@ -33,14 +33,14 @@
},
"home": {
"hero": {
"title": "Ghid Biblic Studiu biblic online cu AI, versete zilnice și comunitate de rugăciune",
"title": "Biblical Guide Studiu biblic online cu AI, versete zilnice și comunitate de rugăciune",
"subtitle": "Studiu biblic online cu asistență AI",
"description": "Ghid Biblic este o aplicație de studiu biblic online. Citește Scriptura, pune întrebări cu ajutorul chatului AI, caută versete rapid și alătură-te unei comunități de rugăciune care te sprijină zilnic.",
"description": "Biblical Guide este o aplicație de studiu biblic online. Citește Scriptura, pune întrebări cu ajutorul chatului AI, caută versete rapid și alătură-te unei comunități de rugăciune care te sprijină zilnic.",
"cta": {
"readBible": "Începe să citești",
"askAI": "Încearcă acum gratuit - Chat AI"
},
"liveCounter": "Alătură-te miilor de credincioși care folosesc Ghid Biblic pentru a înțelege și aplica Cuvântul lui Dumnezeu în viața de zi cu zi"
"liveCounter": "Alătură-te miilor de credincioși care folosesc Biblical Guide pentru a înțelege și aplica Cuvântul lui Dumnezeu în viața de zi cu zi"
},
"features": {
"title": "Descoperă funcționalitățile",
@@ -173,12 +173,13 @@
"subscribe": "Abonează-te"
},
"footer": {
"brand": "Ghid Biblic",
"brand": "Biblical Guide",
"description": "O platformă modernă pentru studiul Bibliei cu înțelegeri AI și suport comunitar.",
"quickLinks": {
"title": "Linkuri Rapide",
"about": "Despre",
"blog": "Blog",
"contact": "Contact",
"support": "Suport",
"api": "Documentație API"
},
@@ -192,7 +193,7 @@
"social": {
"title": "Urmărește-ne"
},
"copyright": "© 2024 Ghid Biblic - Făcut cu ❤️ și 🙏"
"copyright": "© 2024 Biblical Guide - Făcut cu ❤️ și 🙏"
}
},
"pages": {
@@ -201,6 +202,7 @@
"subtitle": "Explorează Scriptura cu o interfață modernă și intuitivă",
"selectBook": "Selectează cartea",
"selectChapter": "Selectează capitolul",
"version": "Versiunea",
"book": "Cartea",
"chapter": "Capitolul",
"verse": "Versetul",
@@ -482,14 +484,52 @@
"next": "Următorul",
"previous": "Anterior"
},
"contact": {
"hero": {
"title": "Contactează-ne",
"subtitle": "Intră în legătură cu echipa noastră",
"description": "Ai întrebări despre Biblical Guide? Ai nevoie de suport tehnic? Vrei să împărtășești feedback? Suntem aici să te ajutăm în călătoria ta spirituală."
},
"form": {
"title": "Trimite-ne un mesaj",
"description": "Completează formularul de mai jos și îți vom răspunde cât mai repede posibil.",
"fields": {
"name": "Numele tău",
"email": "Adresa de email",
"subject": "Subiect",
"message": "Mesajul tău"
},
"submit": "Trimite mesajul",
"submitting": "Se trimite...",
"success": "Mulțumim pentru mesaj! Îți vom răspunde în curând.",
"error": "A apărut o eroare la trimiterea mesajului. Te rog să încerci din nou."
},
"info": {
"title": "Informații de contact",
"description": "Contactează-ne prin oricare dintre aceste canale:",
"email": {
"title": "Suport Email",
"content": "contact@biblical-guide.com"
},
"address": {
"title": "Biroul nostru",
"content": "Strada Bibliei 123, București, România"
}
},
"faq": {
"title": "Întrebări frecvente",
"description": "Găsește răspunsuri rapide la întrebările comune despre Biblical Guide.",
"viewFaq": "Vezi FAQ"
}
},
"seo": {
"title": "Ghid Biblic Studiu Biblic Online cu AI, Versete Zilnice și Comunitate de Rugăciune",
"description": "Ghid Biblic este o aplicație de studiu biblic online cu chat AI, căutare rapidă de versete și o comunitate de rugăciune. Primește versete zilnice și răspunsuri clare din Scriptură.",
"title": "Biblical Guide Studiu Biblic Online cu AI, Versete Zilnice și Comunitate de Rugăciune",
"description": "Biblical Guide este o aplicație de studiu biblic online cu chat AI, căutare rapidă de versete și o comunitate de rugăciune. Primește versete zilnice și răspunsuri clare din Scriptură.",
"keywords": "studiu biblic online, aplicație biblică, chat AI biblic, versete biblice zilnice, comunitate de rugăciune, citește Biblia online, căutare versete, Biblia Română",
"ogTitle": "Ghid Biblic Studiu Biblic Online cu AI",
"ogTitle": "Biblical Guide Studiu Biblic Online cu AI",
"ogDescription": "Citește Biblia online, pune întrebări prin chat AI și alătură-te unei comunități de rugăciune. Primește versete zilnice în inbox.",
"twitterTitle": "Ghid Biblic Studiu Biblic Online cu AI",
"twitterTitle": "Biblical Guide Studiu Biblic Online cu AI",
"twitterDescription": "Aplicație biblică online cu chat AI, versete zilnice și comunitate de rugăciune.",
"footer": "Ghid Biblic aplicație de studiu biblic online, cu chat AI, versete zilnice și comunitate de rugăciune."
"footer": "Biblical Guide aplicație de studiu biblic online, cu chat AI, versete zilnice și comunitate de rugăciune."
}
}

27
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@tailwindcss/postcss": "^4.1.13",
"@tinymce/tinymce-react": "^6.3.0",
"@types/node": "^24.5.2",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.15.5",
@@ -58,6 +59,7 @@
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tinymce": "^8.1.2",
"typescript": "^5.9.2",
"zod": "^3.25.76",
"zustand": "^5.0.8"
@@ -3201,6 +3203,25 @@
"tailwindcss": "4.1.13"
}
},
"node_modules/@tinymce/tinymce-react": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz",
"integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1"
},
"peerDependenciesMeta": {
"tinymce": {
"optional": true
}
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -7365,6 +7386,12 @@
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"license": "MIT"
},
"node_modules/tinymce": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.1.2.tgz",
"integrity": "sha512-KITxHEEHRlxC5xOnxA123eAJ67NgsWxNphtItWt9TRu07DiTZrWIqJeIKRX9euE51/l3kJO4WQiqoBXKTJJGsA==",
"license": "GPL-2.0-or-later"
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",

View File

@@ -39,6 +39,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@tailwindcss/postcss": "^4.1.13",
"@tinymce/tinymce-react": "^6.3.0",
"@types/node": "^24.5.2",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.15.5",
@@ -68,6 +69,7 @@
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tinymce": "^8.1.2",
"typescript": "^5.9.2",
"zod": "^3.25.76",
"zustand": "^5.0.8"

View File

@@ -29,6 +29,9 @@ model User {
userPrayers UserPrayer[]
readingHistory ReadingHistory[]
preferences UserPreference[]
createdPages Page[] @relation("PageCreator")
updatedPages Page[] @relation("PageUpdater")
uploadedFiles MediaFile[]
@@index([role])
}
@@ -52,6 +55,10 @@ model BibleVersion {
abbreviation String // e.g., "KJV", "CORNILESCU", "NIV"
language String // e.g., "en", "ro", "es"
description String?
country String?
englishTitle String?
flagImageUrl String?
zipFileUrl String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -280,4 +287,64 @@ model UserPreference {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, key])
}
model Page {
id String @id @default(uuid())
title String
slug String @unique
content String @db.Text
contentType PageContentType @default(RICH_TEXT)
excerpt String? @db.Text
featuredImage String?
seoTitle String?
seoDescription String?
status PageStatus @default(DRAFT)
showInNavigation Boolean @default(false)
showInFooter Boolean @default(false)
navigationOrder Int?
footerOrder Int?
createdBy String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
creator User @relation("PageCreator", fields: [createdBy], references: [id])
updater User @relation("PageUpdater", fields: [updatedBy], references: [id])
@@index([slug])
@@index([status])
@@index([showInNavigation, navigationOrder])
@@index([showInFooter, footerOrder])
}
model MediaFile {
id String @id @default(uuid())
filename String
originalName String
mimeType String
size Int
path String
url String
alt String?
uploadedBy String
createdAt DateTime @default(now())
uploader User @relation(fields: [uploadedBy], references: [id])
@@index([uploadedBy])
@@index([mimeType])
}
enum PageContentType {
RICH_TEXT
HTML
MARKDOWN
}
enum PageStatus {
DRAFT
PUBLISHED
ARCHIVED
}

View File

@@ -0,0 +1,423 @@
import fs from 'fs'
import path from 'path'
interface BibleMetadata {
Country: string
flag_Image: string
Language: string
Language_English: string
Vernacular_Bible_Title: string
English_Bible_Title: string
file_ID: string
}
interface ParsedVerse {
book: string
chapter: number
verse: number
text: string
}
interface JsonVerse {
verseNum: number
text: string
}
interface JsonChapter {
chapterNum: number
verses: JsonVerse[]
}
interface JsonBook {
bookKey: string
name: string
testament: string
orderNum: number
chapters: JsonChapter[]
}
interface JsonBibleVersion {
name: string
abbreviation: string
language: string
description: string
country: string
englishTitle: string
zipFileUrl: string
flagImageUrl: string
isDefault: boolean
books: JsonBook[]
}
// Book name mappings from VPL format to normalized keys
const BOOK_MAPPINGS: Record<string, string> = {
// Old Testament
'GEN': 'genesis', 'EXO': 'exodus', 'LEV': 'leviticus', 'NUM': 'numbers', 'DEU': 'deuteronomy',
'JOS': 'joshua', 'JDG': 'judges', 'RUT': 'ruth', '1SA': '1-samuel', '2SA': '2-samuel',
'1KI': '1-kings', '2KI': '2-kings', '1CH': '1-chronicles', '2CH': '2-chronicles',
'EZR': 'ezra', 'NEH': 'nehemiah', 'EST': 'esther', 'JOB': 'job', 'PSA': 'psalms',
'PRO': 'proverbs', 'ECC': 'ecclesiastes', 'SNG': 'song-of-songs', 'ISA': 'isaiah',
'JER': 'jeremiah', 'LAM': 'lamentations', 'EZK': 'ezekiel', 'EZE': 'ezekiel', 'DAN': 'daniel',
'HOS': 'hosea', 'JOL': 'joel', 'JOE': 'joel', 'AMO': 'amos', 'OBA': 'obadiah', 'JON': 'jonah',
'MIC': 'micah', 'NAM': 'nahum', 'NAH': 'nahum', 'HAB': 'habakkuk', 'ZEP': 'zephaniah',
'HAG': 'haggai', 'ZEC': 'zechariah', 'MAL': 'malachi',
// New Testament
'MAT': 'matthew', 'MRK': 'mark', 'MAR': 'mark', 'LUK': 'luke', 'JHN': 'john', 'JOH': 'john', 'ACT': 'acts',
'ROM': 'romans', '1CO': '1-corinthians', '2CO': '2-corinthians', 'GAL': 'galatians',
'EPH': 'ephesians', 'PHP': 'philippians', 'PHI': 'philippians', 'COL': 'colossians', '1TH': '1-thessalonians',
'2TH': '2-thessalonians', '1TI': '1-timothy', '2TI': '2-timothy', 'TIT': 'titus',
'PHM': 'philemon', 'HEB': 'hebrews', 'JAS': 'james', 'JAM': 'james', '1PE': '1-peter', '2PE': '2-peter',
'1JN': '1-john', '1JO': '1-john', '2JN': '2-john', '2JO': '2-john', '3JN': '3-john',
'3JO': '3-john', 'JUD': 'jude', 'REV': 'revelation', 'SOL': 'song-of-songs'
}
// Book order numbers (1-66)
const BOOK_ORDER: Record<string, number> = {
'genesis': 1, 'exodus': 2, 'leviticus': 3, 'numbers': 4, 'deuteronomy': 5,
'joshua': 6, 'judges': 7, 'ruth': 8, '1-samuel': 9, '2-samuel': 10,
'1-kings': 11, '2-kings': 12, '1-chronicles': 13, '2-chronicles': 14,
'ezra': 15, 'nehemiah': 16, 'esther': 17, 'job': 18, 'psalms': 19,
'proverbs': 20, 'ecclesiastes': 21, 'song-of-songs': 22, 'isaiah': 23,
'jeremiah': 24, 'lamentations': 25, 'ezekiel': 26, 'daniel': 27,
'hosea': 28, 'joel': 29, 'amos': 30, 'obadiah': 31, 'jonah': 32,
'micah': 33, 'nahum': 34, 'habakkuk': 35, 'zephaniah': 36,
'haggai': 37, 'zechariah': 38, 'malachi': 39,
'matthew': 40, 'mark': 41, 'luke': 42, 'john': 43, 'acts': 44,
'romans': 45, '1-corinthians': 46, '2-corinthians': 47, 'galatians': 48,
'ephesians': 49, 'philippians': 50, 'colossians': 51, '1-thessalonians': 52,
'2-thessalonians': 53, '1-timothy': 54, '2-timothy': 55, 'titus': 56,
'philemon': 57, 'hebrews': 58, 'james': 59, '1-peter': 60, '2-peter': 61,
'1-john': 62, '2-john': 63, '3-john': 64, 'jude': 65, 'revelation': 66
}
function parseVplFile(filePath: string): ParsedVerse[] {
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content.split('\n').filter(line => line.trim())
const verses: ParsedVerse[] = []
for (const line of lines) {
const match = line.match(/^(\w+)\s+(\d+):(\d+)\s+(.+)$/)
if (match) {
const [, bookCode, chapterStr, verseStr, text] = match
const chapter = parseInt(chapterStr, 10)
const verse = parseInt(verseStr, 10)
if (BOOK_MAPPINGS[bookCode]) {
verses.push({
book: BOOK_MAPPINGS[bookCode],
chapter,
verse,
text: text.trim()
})
} else {
console.warn(`Unknown book code: ${bookCode} in ${filePath}`)
}
}
}
return verses
}
function loadBibleMetadata(): BibleMetadata[] {
const csvPath = path.join(process.cwd(), 'bibles', 'bibles_list.csv')
const content = fs.readFileSync(csvPath, 'utf-8')
const lines = content.split('\n').filter(line => line.trim())
const results: BibleMetadata[] = []
// Parse CSV manually handling quoted fields
for (let i = 1; i < lines.length; i++) {
const line = lines[i]
const values: string[] = []
let current = ''
let inQuotes = false
for (let j = 0; j < line.length; j++) {
const char = line[j]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim()) // Add the last value
if (values.length >= 7) {
results.push({
Country: values[0] || '',
flag_Image: values[1] || '',
Language: values[2] || '',
Language_English: values[3] || '',
Vernacular_Bible_Title: values[4] || '',
English_Bible_Title: values[5] || '',
file_ID: values[6] || ''
})
}
}
return results
}
function getTestament(bookKey: string): string {
const orderNum = BOOK_ORDER[bookKey]
return orderNum <= 39 ? 'Old Testament' : 'New Testament'
}
function getLanguageCode(language: string): string {
// Try to extract ISO language code from language string
const langMap: Record<string, string> = {
'english': 'en',
'spanish': 'es',
'french': 'fr',
'german': 'de',
'portuguese': 'pt',
'italian': 'it',
'dutch': 'nl',
'russian': 'ru',
'chinese': 'zh',
'japanese': 'ja',
'korean': 'ko',
'arabic': 'ar',
'hindi': 'hi',
'romanian': 'ro'
}
const lowerLang = language.toLowerCase()
for (const [key, code] of Object.entries(langMap)) {
if (lowerLang.includes(key)) {
return code
}
}
// Default to first 2 characters if no mapping found
return lowerLang.substring(0, 2)
}
function convertVplToJson(metadata: BibleMetadata): JsonBibleVersion | null {
const vplPath = path.join(process.cwd(), 'bibles', 'ebible_vpl', `${metadata.file_ID}_vpl.txt`)
if (!fs.existsSync(vplPath)) {
console.warn(`VPL file not found: ${vplPath}`)
return null
}
console.log(`Converting ${metadata.Vernacular_Bible_Title} (${metadata.file_ID})...`)
const verses = parseVplFile(vplPath)
if (verses.length === 0) {
console.warn(`No verses found in ${vplPath}`)
return null
}
// Create Bible version metadata
const languageCode = getLanguageCode(metadata.Language_English)
const zipFileUrl = `https://ebible.org/Scriptures/${metadata.file_ID}_vpl.zip`
const bibleVersion: JsonBibleVersion = {
name: metadata.Vernacular_Bible_Title,
abbreviation: metadata.file_ID.toUpperCase(),
language: languageCode,
description: `${metadata.English_Bible_Title} - ${metadata.Country}`,
country: metadata.Country.trim(),
englishTitle: metadata.English_Bible_Title,
zipFileUrl: zipFileUrl,
flagImageUrl: metadata.flag_Image,
isDefault: false,
books: []
}
// Group verses by book
const bookGroups = new Map<string, ParsedVerse[]>()
for (const verse of verses) {
if (!bookGroups.has(verse.book)) {
bookGroups.set(verse.book, [])
}
bookGroups.get(verse.book)!.push(verse)
}
// Convert each book
for (const [bookKey, bookVerses] of bookGroups) {
const orderNum = BOOK_ORDER[bookKey]
const testament = getTestament(bookKey)
const book: JsonBook = {
bookKey: bookKey,
name: bookKey.charAt(0).toUpperCase() + bookKey.slice(1).replace('-', ' '),
testament: testament,
orderNum: orderNum,
chapters: []
}
// Group verses by chapter
const chapterGroups = new Map<number, ParsedVerse[]>()
for (const verse of bookVerses) {
if (!chapterGroups.has(verse.chapter)) {
chapterGroups.set(verse.chapter, [])
}
chapterGroups.get(verse.chapter)!.push(verse)
}
// Convert each chapter
for (const [chapterNum, chapterVerses] of chapterGroups) {
const chapter: JsonChapter = {
chapterNum: chapterNum,
verses: []
}
// Sort verses by verse number
chapterVerses.sort((a, b) => a.verse - b.verse)
// Convert verses
for (const verse of chapterVerses) {
chapter.verses.push({
verseNum: verse.verse,
text: verse.text
})
}
book.chapters.push(chapter)
}
// Sort chapters by chapter number
book.chapters.sort((a, b) => a.chapterNum - b.chapterNum)
bibleVersion.books.push(book)
}
// Sort books by order number
bibleVersion.books.sort((a, b) => a.orderNum - b.orderNum)
console.log(`✅ Converted ${verses.length} verses in ${bibleVersion.books.length} books`)
return bibleVersion
}
async function main() {
console.log('🚀 Starting VPL to JSON conversion...')
const outputDir = path.join(process.cwd(), 'bibles', 'json')
const logFilePath = path.join(outputDir, 'conversion_log.txt')
const errorLogPath = path.join(outputDir, 'conversion_errors.txt')
// Initialize log files
const logMessage = (message: string) => {
const timestamp = new Date().toISOString()
const logEntry = `[${timestamp}] ${message}\n`
console.log(message)
fs.appendFileSync(logFilePath, logEntry, 'utf-8')
}
const logError = (message: string, error?: any) => {
const timestamp = new Date().toISOString()
const errorEntry = `[${timestamp}] ERROR: ${message}\n${error ? `Details: ${error.toString()}\n` : ''}\n`
console.error(message, error || '')
fs.appendFileSync(errorLogPath, errorEntry, 'utf-8')
}
try {
// Create output directory
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
logMessage(`📁 Created output directory: ${outputDir}`)
}
// Initialize log files
fs.writeFileSync(logFilePath, `VPL to JSON Conversion Log - Started at ${new Date().toISOString()}\n`, 'utf-8')
fs.writeFileSync(errorLogPath, `VPL to JSON Conversion Errors - Started at ${new Date().toISOString()}\n`, 'utf-8')
// Load Bible metadata
logMessage('📋 Loading Bible metadata...')
const metadata = loadBibleMetadata()
logMessage(`Found ${metadata.length} Bible versions to convert`)
// Force re-conversion of all files for end-to-end testing
logMessage(`Force re-converting all files for complete end-to-end process`)
// Convert each Bible version
let converted = 0
let skipped = 0
let resumed = 0
for (const bibleData of metadata) {
try {
const jsonBible = convertVplToJson(bibleData)
if (jsonBible) {
// Save individual Bible JSON file
const filename = `${bibleData.file_ID}_bible.json`
const filepath = path.join(outputDir, filename)
fs.writeFileSync(filepath, JSON.stringify(jsonBible, null, 2), 'utf-8')
logMessage(`💾 Saved: ${filename}`)
converted++
// Progress update every 10 conversions
if (converted % 10 === 0) {
logMessage(`📈 Progress: ${converted}/${metadata.length} converted...`)
// Force garbage collection to prevent memory buildup
if (global.gc) {
global.gc()
}
}
} else {
skipped++
logError(`Skipped ${bibleData.file_ID}: No valid Bible data found`)
}
} catch (error) {
logError(`Failed to convert ${bibleData.file_ID}`, error)
skipped++
}
}
// Skip creating large master file to prevent memory issues
logMessage('\n📦 Skipping master file creation to prevent memory issues...')
// Final summary
const totalProcessed = converted + skipped + resumed
logMessage('\n✅ VPL to JSON conversion completed!')
logMessage(`📊 Final Summary:`)
logMessage(` - Successfully converted: ${converted}`)
logMessage(` - Already converted (resumed): ${resumed}`)
logMessage(` - Skipped due to errors: ${skipped}`)
logMessage(` - Total processed: ${totalProcessed}`)
logMessage(` - Output directory: ${outputDir}`)
logMessage(` - Individual files: ${converted + resumed} Bible JSON files`)
logMessage(` - Log file: conversion_log.txt`)
logMessage(` - Error file: conversion_errors.txt`)
// Create a simple summary without loading all data into memory
const allJsonFiles = fs.readdirSync(outputDir).filter(f => f.endsWith('_bible.json'))
const summaryData = {
conversionSummary: {
totalJsonFiles: allJsonFiles.length,
totalAttempted: metadata.length,
successfullyConverted: converted,
alreadyExisted: resumed,
skippedDueToErrors: skipped,
completedAt: new Date().toISOString()
},
availableFiles: allJsonFiles.sort()
}
const summaryFile = path.join(outputDir, 'conversion_summary.json')
fs.writeFileSync(summaryFile, JSON.stringify(summaryData, null, 2), 'utf-8')
logMessage(`📊 Summary file saved: conversion_summary.json`)
} catch (error) {
logError('Conversion failed with fatal error', error)
throw error
}
}
main()
.then(() => {
console.log('🎉 Conversion process completed successfully!')
process.exit(0)
})
.catch((error) => {
console.error('💥 Conversion failed:', error)
process.exit(1)
})

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""
Import JSON Bible files into the database.
Skips files under 500KB and handles database constraints properly.
"""
import os
import json
import psycopg
from urllib.parse import urlparse
from dotenv import load_dotenv
from typing import Dict, List, Optional
import sys
from datetime import datetime
import uuid
# Load environment variables
load_dotenv()
def get_db_connection():
"""Get connection to biblical-guide database"""
db_url = os.getenv("DATABASE_URL")
if not db_url:
raise ValueError("DATABASE_URL environment variable not found")
parsed = urlparse(db_url)
conn_str = f"host={parsed.hostname} port={parsed.port or 5432} user={parsed.username} password={parsed.password} dbname=biblical-guide"
return psycopg.connect(conn_str)
def get_file_size_kb(file_path: str) -> float:
"""Get file size in KB"""
return os.path.getsize(file_path) / 1024
def load_json_file(file_path: str) -> Optional[Dict]:
"""Load and parse JSON file"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"❌ Error loading {file_path}: {e}")
return None
def get_language_code(language: str) -> str:
"""Convert language to proper ISO code"""
lang_map = {
'english': 'en',
'spanish': 'es',
'french': 'fr',
'german': 'de',
'portuguese': 'pt',
'italian': 'it',
'dutch': 'nl',
'russian': 'ru',
'chinese': 'zh',
'japanese': 'ja',
'korean': 'ko',
'arabic': 'ar',
'hindi': 'hi',
'romanian': 'ro'
}
lower_lang = language.lower()
for key, code in lang_map.items():
if key in lower_lang:
return code
# Default to first 2 characters if no mapping found
return lower_lang[:2] if len(lower_lang) >= 2 else 'xx'
def bible_version_exists(conn, abbreviation: str, language: str) -> bool:
"""Check if Bible version already exists"""
with conn.cursor() as cur:
cur.execute('''
SELECT COUNT(*) FROM "BibleVersion"
WHERE abbreviation = %s AND language = %s
''', (abbreviation, language))
return cur.fetchone()[0] > 0
def import_bible_version(conn, bible_data: Dict) -> Optional[str]:
"""Import a Bible version and return its ID"""
try:
# Extract and clean data
name = bible_data.get('name', '').strip()
abbreviation = bible_data.get('abbreviation', '').strip()
language = get_language_code(bible_data.get('language', ''))
description = bible_data.get('description', '').strip()
country = bible_data.get('country', '').strip()
english_title = bible_data.get('englishTitle', '').strip()
zip_file_url = bible_data.get('zipFileUrl', '').strip()
flag_image_url = bible_data.get('flagImageUrl', '').strip()
is_default = bible_data.get('isDefault', False)
# Validate required fields
if not name or not abbreviation:
print(f"⚠️ Skipping Bible: missing name or abbreviation")
return None
# Check for duplicates
if bible_version_exists(conn, abbreviation, language):
print(f"⚠️ Bible version {abbreviation} ({language}) already exists, skipping...")
return None
# Insert Bible version
version_id = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute('''
INSERT INTO "BibleVersion" (
id, name, abbreviation, language, description, country,
"englishTitle", "zipFileUrl", "flagImageUrl", "isDefault",
"createdAt", "updatedAt"
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
''', (
version_id, name, abbreviation, language, description, country,
english_title, zip_file_url, flag_image_url, is_default
))
conn.commit()
print(f"✅ Created Bible version: {name} ({abbreviation})")
return version_id
except Exception as e:
conn.rollback()
print(f"❌ Error importing Bible version: {e}")
return None
def import_bible_books(conn, version_id: str, books_data: List[Dict]) -> int:
"""Import Bible books for a version"""
imported_count = 0
try:
for book_data in books_data:
book_key = book_data.get('bookKey', '').strip()
name = book_data.get('name', '').strip()
testament = book_data.get('testament', '').strip()
order_num = book_data.get('orderNum', 0)
chapters_data = book_data.get('chapters', [])
if not book_key or not name or not testament:
print(f"⚠️ Skipping book: missing required fields")
continue
# Insert book
book_id = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute('''
INSERT INTO "BibleBook" (
id, "versionId", name, testament, "orderNum", "bookKey"
) VALUES (%s, %s, %s, %s, %s, %s)
''', (book_id, version_id, name, testament, order_num, book_key))
# Import chapters for this book
chapters_imported = import_bible_chapters(conn, book_id, chapters_data)
if chapters_imported > 0:
imported_count += 1
print(f" 📖 {name}: {chapters_imported} chapters")
conn.commit()
return imported_count
except Exception as e:
conn.rollback()
print(f"❌ Error importing books: {e}")
return 0
def import_bible_chapters(conn, book_id: str, chapters_data: List[Dict]) -> int:
"""Import Bible chapters for a book"""
imported_count = 0
try:
for chapter_data in chapters_data:
chapter_num = chapter_data.get('chapterNum', 0)
verses_data = chapter_data.get('verses', [])
if chapter_num <= 0:
print(f"⚠️ Skipping chapter: invalid chapter number")
continue
# Insert chapter
chapter_id = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute('''
INSERT INTO "BibleChapter" (
id, "bookId", "chapterNum"
) VALUES (%s, %s, %s)
''', (chapter_id, book_id, chapter_num))
# Import verses for this chapter
verses_imported = import_bible_verses(conn, chapter_id, verses_data)
if verses_imported > 0:
imported_count += 1
return imported_count
except Exception as e:
print(f"❌ Error importing chapters: {e}")
return 0
def import_bible_verses(conn, chapter_id: str, verses_data: List[Dict]) -> int:
"""Import Bible verses for a chapter"""
imported_count = 0
try:
# Batch insert verses for better performance
verses_to_insert = []
for verse_data in verses_data:
verse_num = verse_data.get('verseNum', 0)
text = verse_data.get('text', '').strip()
if verse_num <= 0 or not text:
continue
verse_id = str(uuid.uuid4())
verses_to_insert.append((verse_id, chapter_id, verse_num, text))
if verses_to_insert:
with conn.cursor() as cur:
cur.executemany('''
INSERT INTO "BibleVerse" (
id, "chapterId", "verseNum", text
) VALUES (%s, %s, %s, %s)
''', verses_to_insert)
imported_count = len(verses_to_insert)
return imported_count
except Exception as e:
print(f"❌ Error importing verses: {e}")
return 0
def main():
"""Main import function"""
print("🚀 Starting JSON Bible import...")
json_dir = os.path.join(os.getcwd(), 'bibles', 'json')
if not os.path.exists(json_dir):
print(f"❌ JSON directory not found: {json_dir}")
sys.exit(1)
# Get all JSON Bible files
json_files = [f for f in os.listdir(json_dir) if f.endswith('_bible.json')]
print(f"📁 Found {len(json_files)} JSON Bible files")
# Filter by file size (skip files under 500KB)
valid_files = []
skipped_small = 0
for file in json_files:
file_path = os.path.join(json_dir, file)
size_kb = get_file_size_kb(file_path)
if size_kb >= 500:
valid_files.append((file, file_path, size_kb))
else:
skipped_small += 1
print(f"📏 Filtered files: {len(valid_files)} valid (≥500KB), {skipped_small} skipped (<500KB)")
# Sort by file size (largest first for better progress visibility)
valid_files.sort(key=lambda x: x[2], reverse=True)
# Connect to database
try:
conn = get_db_connection()
print("🔗 Connected to database")
except Exception as e:
print(f"❌ Database connection failed: {e}")
sys.exit(1)
# Import statistics
stats = {
'total_files': len(valid_files),
'imported': 0,
'skipped': 0,
'errors': 0,
'total_books': 0,
'total_chapters': 0,
'total_verses': 0
}
# Process each file
for i, (filename, file_path, size_kb) in enumerate(valid_files, 1):
print(f"\n📖 [{i}/{len(valid_files)}] Processing {filename} ({size_kb:.1f}KB)")
try:
# Load JSON data
bible_data = load_json_file(file_path)
if not bible_data:
stats['errors'] += 1
continue
# Import Bible version
version_id = import_bible_version(conn, bible_data)
if not version_id:
stats['skipped'] += 1
continue
# Import books
books_data = bible_data.get('books', [])
books_imported = import_bible_books(conn, version_id, books_data)
if books_imported > 0:
stats['imported'] += 1
stats['total_books'] += books_imported
# Count chapters and verses
for book in books_data:
chapters = book.get('chapters', [])
stats['total_chapters'] += len(chapters)
for chapter in chapters:
stats['total_verses'] += len(chapter.get('verses', []))
print(f"✅ Successfully imported {books_imported} books")
else:
stats['errors'] += 1
# Progress update every 10 files
if i % 10 == 0:
progress = (i / len(valid_files)) * 100
print(f"\n📈 Progress: {progress:.1f}% ({stats['imported']} imported, {stats['skipped']} skipped, {stats['errors']} errors)")
except Exception as e:
print(f"❌ Error processing {filename}: {e}")
stats['errors'] += 1
# Close database connection
conn.close()
# Final summary
print(f"\n🎉 JSON Bible import completed!")
print(f"📊 Final Statistics:")
print(f" - Total files processed: {stats['total_files']}")
print(f" - Successfully imported: {stats['imported']}")
print(f" - Skipped (duplicates): {stats['skipped']}")
print(f" - Errors: {stats['errors']}")
print(f" - Files skipped (<500KB): {skipped_small}")
print(f" - Total books imported: {stats['total_books']}")
print(f" - Total chapters imported: {stats['total_chapters']}")
print(f" - Total verses imported: {stats['total_verses']}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n⚠️ Import interrupted by user")
sys.exit(1)
except Exception as e:
print(f"❌ Fatal error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)