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:
@@ -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: {
|
||||
|
||||
281
app/[locale]/contact/page.tsx
Normal file
281
app/[locale]/contact/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
309
app/[locale]/pages/[slug]/page.tsx
Normal file
309
app/[locale]/pages/[slug]/page.tsx
Normal 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
357
app/admin/pages/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
app/api/admin/media/route.ts
Normal file
137
app/api/admin/media/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
174
app/api/admin/pages/[id]/route.ts
Normal file
174
app/api/admin/pages/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
144
app/api/admin/pages/route.ts
Normal file
144
app/api/admin/pages/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
47
app/api/pages/[slug]/route.ts
Normal file
47
app/api/pages/[slug]/route.ts
Normal 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
53
app/api/pages/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
329
components/admin/pages/image-upload.tsx
Normal file
329
components/admin/pages/image-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
471
components/admin/pages/page-editor.tsx
Normal file
471
components/admin/pages/page-editor.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
components/layout/footer.tsx
Normal file
167
components/layout/footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
4
lib/cache/index.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
27
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
423
scripts/convert_bibles_to_json.ts
Normal file
423
scripts/convert_bibles_to_json.ts
Normal 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)
|
||||
})
|
||||
353
scripts/import_json_bibles.py
Normal file
353
scripts/import_json_bibles.py
Normal 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)
|
||||
Reference in New Issue
Block a user