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[]
|
verses: BibleVerse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BibleVersion {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
abbreviation: string
|
||||||
|
language: string
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface BibleBook {
|
interface BibleBook {
|
||||||
id: string
|
id: string
|
||||||
versionId: string
|
versionId: string
|
||||||
@@ -122,10 +130,13 @@ export default function BibleReaderNew() {
|
|||||||
|
|
||||||
// Core state
|
// Core state
|
||||||
const [books, setBooks] = useState<BibleBook[]>([])
|
const [books, setBooks] = useState<BibleBook[]>([])
|
||||||
|
const [versions, setVersions] = useState<BibleVersion[]>([])
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState<string>('')
|
||||||
const [selectedBook, setSelectedBook] = useState<string>('')
|
const [selectedBook, setSelectedBook] = useState<string>('')
|
||||||
const [selectedChapter, setSelectedChapter] = useState<number>(1)
|
const [selectedChapter, setSelectedChapter] = useState<number>(1)
|
||||||
const [verses, setVerses] = useState<BibleVerse[]>([])
|
const [verses, setVerses] = useState<BibleVerse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [versionsLoading, setVersionsLoading] = useState(true)
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
@@ -172,13 +183,29 @@ export default function BibleReaderNew() {
|
|||||||
console.error('Failed to parse preferences:', e)
|
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
|
// Save preferences to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('bibleReaderPreferences', JSON.stringify(preferences))
|
localStorage.setItem('bibleReaderPreferences', JSON.stringify(preferences))
|
||||||
}, [preferences])
|
}, [preferences])
|
||||||
|
|
||||||
|
// Save selected version to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedVersion) {
|
||||||
|
localStorage.setItem('selectedBibleVersion', selectedVersion)
|
||||||
|
}
|
||||||
|
}, [selectedVersion])
|
||||||
|
|
||||||
// Scroll handler for show scroll to top button
|
// Scroll handler for show scroll to top button
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -189,29 +216,60 @@ export default function BibleReaderNew() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll)
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Fetch books
|
// Fetch versions based on current locale
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/bible/books?locale=${locale}`)
|
setVersionsLoading(true)
|
||||||
|
fetch(`/api/bible/versions?language=${locale}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setBooks(data.books || [])
|
if (data.success && data.versions) {
|
||||||
if (data.books && data.books.length > 0) {
|
setVersions(data.versions)
|
||||||
setSelectedBook(data.books[0].id)
|
// 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 => {
|
.catch(err => {
|
||||||
console.error('Error fetching books:', err)
|
console.error('Error fetching versions:', err)
|
||||||
setLoading(false)
|
setVersionsLoading(false)
|
||||||
})
|
})
|
||||||
}, [locale])
|
}, [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
|
// Handle URL parameters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (books.length > 0) {
|
if (books.length > 0 && versions.length > 0) {
|
||||||
const bookParam = searchParams.get('book')
|
const bookParam = searchParams.get('book')
|
||||||
const chapterParam = searchParams.get('chapter')
|
const chapterParam = searchParams.get('chapter')
|
||||||
const verseParam = searchParams.get('verse')
|
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) {
|
if (bookParam) {
|
||||||
const book = books.find(b => b.id === bookParam) || books.find(b => b.bookKey === 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
|
// Fetch verses when book/chapter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -350,10 +408,13 @@ export default function BibleReaderNew() {
|
|||||||
const currentBook = books.find(book => book.id === selectedBook)
|
const currentBook = books.find(book => book.id === selectedBook)
|
||||||
const maxChapters = currentBook?.chapters?.length || 1
|
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)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('book', bookId)
|
url.searchParams.set('book', bookId)
|
||||||
url.searchParams.set('chapter', chapter.toString())
|
url.searchParams.set('chapter', chapter.toString())
|
||||||
|
if (version) {
|
||||||
|
url.searchParams.set('version', version)
|
||||||
|
}
|
||||||
window.history.replaceState({}, '', url.toString())
|
window.history.replaceState({}, '', url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +429,7 @@ export default function BibleReaderNew() {
|
|||||||
if (selectedChapter > 1) {
|
if (selectedChapter > 1) {
|
||||||
const newChapter = selectedChapter - 1
|
const newChapter = selectedChapter - 1
|
||||||
setSelectedChapter(newChapter)
|
setSelectedChapter(newChapter)
|
||||||
updateUrl(selectedBook, newChapter)
|
updateUrl(selectedBook, newChapter, selectedVersion)
|
||||||
} else {
|
} else {
|
||||||
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
|
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
|
||||||
if (currentBookIndex > 0) {
|
if (currentBookIndex > 0) {
|
||||||
@@ -376,7 +437,7 @@ export default function BibleReaderNew() {
|
|||||||
const lastChapter = previousBook.chapters?.length || 1
|
const lastChapter = previousBook.chapters?.length || 1
|
||||||
setSelectedBook(previousBook.id)
|
setSelectedBook(previousBook.id)
|
||||||
setSelectedChapter(lastChapter)
|
setSelectedChapter(lastChapter)
|
||||||
updateUrl(previousBook.id, lastChapter)
|
updateUrl(previousBook.id, lastChapter, selectedVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,14 +446,14 @@ export default function BibleReaderNew() {
|
|||||||
if (selectedChapter < maxChapters) {
|
if (selectedChapter < maxChapters) {
|
||||||
const newChapter = selectedChapter + 1
|
const newChapter = selectedChapter + 1
|
||||||
setSelectedChapter(newChapter)
|
setSelectedChapter(newChapter)
|
||||||
updateUrl(selectedBook, newChapter)
|
updateUrl(selectedBook, newChapter, selectedVersion)
|
||||||
} else {
|
} else {
|
||||||
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
|
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
|
||||||
if (currentBookIndex < books.length - 1) {
|
if (currentBookIndex < books.length - 1) {
|
||||||
const nextBook = books[currentBookIndex + 1]
|
const nextBook = books[currentBookIndex + 1]
|
||||||
setSelectedBook(nextBook.id)
|
setSelectedBook(nextBook.id)
|
||||||
setSelectedChapter(1)
|
setSelectedChapter(1)
|
||||||
updateUrl(nextBook.id, 1)
|
updateUrl(nextBook.id, 1, selectedVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,7 +554,7 @@ export default function BibleReaderNew() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleShare = () => {
|
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(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
setCopyFeedback({
|
setCopyFeedback({
|
||||||
open: true,
|
open: true,
|
||||||
@@ -614,6 +675,33 @@ export default function BibleReaderNew() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
|
<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 */}
|
{/* Books Selection */}
|
||||||
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 200, md: 250 } }}>
|
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 200, md: 250 } }}>
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
@@ -624,7 +712,7 @@ export default function BibleReaderNew() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedBook(e.target.value)
|
setSelectedBook(e.target.value)
|
||||||
setSelectedChapter(1)
|
setSelectedChapter(1)
|
||||||
updateUrl(e.target.value, 1)
|
updateUrl(e.target.value, 1, selectedVersion)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{books.map((book) => (
|
{books.map((book) => (
|
||||||
@@ -646,7 +734,7 @@ export default function BibleReaderNew() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newChapter = Number(e.target.value)
|
const newChapter = Number(e.target.value)
|
||||||
setSelectedChapter(newChapter)
|
setSelectedChapter(newChapter)
|
||||||
updateUrl(selectedBook, newChapter)
|
updateUrl(selectedBook, newChapter, selectedVersion)
|
||||||
}}
|
}}
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
PaperProps: {
|
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 { MuiThemeProvider } from '@/components/providers/theme-provider'
|
||||||
import { AuthProvider } from '@/components/auth/auth-provider'
|
import { AuthProvider } from '@/components/auth/auth-provider'
|
||||||
import { Navigation } from '@/components/layout/navigation'
|
import { Navigation } from '@/components/layout/navigation'
|
||||||
|
import { Footer } from '@/components/layout/footer'
|
||||||
import FloatingChat from '@/components/chat/floating-chat'
|
import FloatingChat from '@/components/chat/floating-chat'
|
||||||
import { merriweather, lato } from '@/lib/fonts'
|
import { merriweather, lato } from '@/lib/fonts'
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||||||
title: t('ogTitle'),
|
title: t('ogTitle'),
|
||||||
description: t('ogDescription'),
|
description: t('ogDescription'),
|
||||||
url: currentUrl,
|
url: currentUrl,
|
||||||
siteName: locale === 'ro' ? 'Ghid Biblic' : 'Biblical Guide',
|
siteName: 'Biblical Guide',
|
||||||
locale: locale,
|
locale: locale,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [
|
images: [
|
||||||
@@ -106,6 +107,7 @@ export default async function LocaleLayout({
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
<FloatingChat />
|
<FloatingChat />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</MuiThemeProvider>
|
</MuiThemeProvider>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Chip,
|
Chip,
|
||||||
Avatar,
|
Avatar,
|
||||||
Divider,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
@@ -31,10 +30,6 @@ import {
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
QuestionAnswer,
|
QuestionAnswer,
|
||||||
Facebook,
|
|
||||||
Twitter,
|
|
||||||
Instagram,
|
|
||||||
YouTube,
|
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
@@ -613,98 +608,6 @@ export default function Home() {
|
|||||||
</Container>
|
</Container>
|
||||||
</Paper>
|
</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>
|
</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')
|
console.log('Books API called')
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const locale = searchParams.get('locale') || 'ro'
|
const locale = searchParams.get('locale') || 'ro'
|
||||||
const versionAbbr = searchParams.get('version') // Optional specific version
|
const versionId = searchParams.get('version') // Optional specific version ID
|
||||||
console.log('Locale:', locale, 'Version:', versionAbbr)
|
console.log('Locale:', locale, 'Version ID:', versionId)
|
||||||
|
|
||||||
// Get the appropriate Bible version
|
// Get the appropriate Bible version
|
||||||
let bibleVersion
|
let bibleVersion
|
||||||
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
|
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
|
||||||
if (versionAbbr) {
|
if (versionId) {
|
||||||
// Use specific version if provided
|
// Use specific version if provided
|
||||||
bibleVersion = await prisma.bibleVersion.findFirst({
|
bibleVersion = await prisma.bibleVersion.findFirst({
|
||||||
where: {
|
where: {
|
||||||
abbreviation: versionAbbr,
|
id: versionId,
|
||||||
language: { in: langCandidates }
|
language: { in: langCandidates }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const bookId = searchParams.get('book') || ''
|
const bookId = searchParams.get('book') || ''
|
||||||
const chapterNum = parseInt(searchParams.get('chapter') || '1')
|
const chapterNum = parseInt(searchParams.get('chapter') || '1')
|
||||||
|
const versionId = searchParams.get('version') || ''
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first (include version in cache key)
|
||||||
const cacheKey = CacheManager.getChapterKey(bookId, chapterNum)
|
const cacheKey = CacheManager.getChapterKey(bookId, chapterNum, versionId)
|
||||||
const cachedChapter = await CacheManager.get(cacheKey)
|
const cachedChapter = await CacheManager.get(cacheKey)
|
||||||
|
|
||||||
if (cachedChapter) {
|
if (cachedChapter) {
|
||||||
@@ -25,7 +26,8 @@ export async function GET(request: Request) {
|
|||||||
const chapter = await prisma.bibleChapter.findFirst({
|
const chapter = await prisma.bibleChapter.findFirst({
|
||||||
where: {
|
where: {
|
||||||
bookId,
|
bookId,
|
||||||
chapterNum
|
chapterNum,
|
||||||
|
book: versionId ? { versionId } : undefined
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
verses: {
|
verses: {
|
||||||
@@ -33,7 +35,11 @@ export async function GET(request: Request) {
|
|||||||
verseNum: 'asc'
|
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,
|
Logout,
|
||||||
AccountCircle,
|
AccountCircle,
|
||||||
AdminPanelSettings,
|
AdminPanelSettings,
|
||||||
Launch as LaunchIcon
|
Launch as LaunchIcon,
|
||||||
|
Article as PageIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
@@ -49,6 +50,7 @@ const drawerWidth = 280;
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
|
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
|
||||||
{ text: 'Users', icon: People, href: '/admin/users' },
|
{ text: 'Users', icon: People, href: '/admin/users' },
|
||||||
|
{ text: 'Pages', icon: PageIcon, href: '/admin/pages' },
|
||||||
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
||||||
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
||||||
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
{ 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'
|
'use client'
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Box,
|
Box,
|
||||||
@@ -38,10 +38,19 @@ import { useTranslations, useLocale } from 'next-intl'
|
|||||||
import { LanguageSwitcher } from './language-switcher'
|
import { LanguageSwitcher } from './language-switcher'
|
||||||
import { useAuth } from '@/hooks/use-auth'
|
import { useAuth } from '@/hooks/use-auth'
|
||||||
|
|
||||||
|
interface DynamicPage {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
showInNavigation: boolean
|
||||||
|
navigationOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
export function Navigation() {
|
export function Navigation() {
|
||||||
const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null)
|
const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null)
|
||||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
@@ -49,13 +58,37 @@ export function Navigation() {
|
|||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const { user, isAuthenticated, logout } = useAuth()
|
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('home'), path: '/', icon: <Home /> },
|
||||||
{ name: t('bible'), path: '/bible', icon: <MenuBook /> },
|
{ name: t('bible'), path: '/bible', icon: <MenuBook /> },
|
||||||
{ name: t('prayers'), path: '/prayers', icon: <Prayer /> },
|
{ name: t('prayers'), path: '/prayers', icon: <Prayer /> },
|
||||||
{ name: t('search'), path: '/search', icon: <Search /> },
|
{ 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 = [
|
const authenticatedPages = [
|
||||||
...pages,
|
...pages,
|
||||||
{ name: t('bookmarks'), path: '/bookmarks', icon: <Bookmark /> },
|
{ name: t('bookmarks'), path: '/bookmarks', icon: <Bookmark /> },
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function Navigation() {
|
|||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="text-xl font-bold text-blue-600 hover:text-blue-700 transition-colors"
|
className="text-xl font-bold text-blue-600 hover:text-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Ghid Biblic
|
Biblical Guide
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="hidden md:flex space-x-4">
|
<div className="hidden md:flex space-x-4">
|
||||||
|
|||||||
@@ -1,52 +1,41 @@
|
|||||||
import { User } from '@prisma/client';
|
import { NextRequest } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { verify } from 'jsonwebtoken';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-key';
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
permissions: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AdminPermission {
|
export async function verifyAdminAuth(request: NextRequest): Promise<AdminUser | null> {
|
||||||
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> {
|
|
||||||
try {
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: {
|
||||||
|
id: payload.userId,
|
||||||
|
role: { in: ['admin', 'moderator'] }
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: 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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return user;
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
permissions: getAdminPermissions(user.role)
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error verifying admin auth:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentAdmin(): Promise<AdminUser | null> {
|
export function hasAdminAccess(user: AdminUser | null): boolean {
|
||||||
const cookieStore = await cookies();
|
return user?.role === 'admin' || user?.role === 'moderator';
|
||||||
const token = cookieStore.get('adminToken')?.value;
|
|
||||||
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
return verifyAdminToken(token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAdminToken(user: User): string {
|
export function isSuperAdmin(user: AdminUser | null): boolean {
|
||||||
if (!['admin', 'moderator'].includes(user.role)) {
|
return user?.role === 'admin';
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
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
|
// Helper methods for specific cache patterns
|
||||||
static getChapterKey(bookId: string, chapterNum: number): string {
|
static getChapterKey(bookId: string, chapterNum: number, versionId?: string): string {
|
||||||
return `chapter:${bookId}:${chapterNum}`
|
return versionId ? `chapter:${bookId}:${chapterNum}:${versionId}` : `chapter:${bookId}:${chapterNum}`
|
||||||
}
|
}
|
||||||
|
|
||||||
static getSearchKey(query: string, limit: number): string {
|
static getSearchKey(query: string, limit: number): string {
|
||||||
|
|||||||
@@ -179,6 +179,7 @@
|
|||||||
"title": "Quick Links",
|
"title": "Quick Links",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
|
"contact": "Contact",
|
||||||
"support": "Support",
|
"support": "Support",
|
||||||
"api": "API Docs"
|
"api": "API Docs"
|
||||||
},
|
},
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"subtitle": "Explore Scripture with a modern and intuitive interface",
|
"subtitle": "Explore Scripture with a modern and intuitive interface",
|
||||||
"selectBook": "Select book",
|
"selectBook": "Select book",
|
||||||
"selectChapter": "Select chapter",
|
"selectChapter": "Select chapter",
|
||||||
|
"version": "Version",
|
||||||
"book": "Book",
|
"book": "Book",
|
||||||
"chapter": "Chapter",
|
"chapter": "Chapter",
|
||||||
"verse": "Verse",
|
"verse": "Verse",
|
||||||
@@ -482,6 +484,44 @@
|
|||||||
"next": "Next",
|
"next": "Next",
|
||||||
"previous": "Previous"
|
"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": {
|
"seo": {
|
||||||
"title": "Biblical Guide – Online Bible Study with AI, Daily Verses & Prayer Community",
|
"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.",
|
"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": {
|
"home": {
|
||||||
"hero": {
|
"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",
|
"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": {
|
"cta": {
|
||||||
"readBible": "Începe să citești",
|
"readBible": "Începe să citești",
|
||||||
"askAI": "Încearcă acum gratuit - Chat AI"
|
"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": {
|
"features": {
|
||||||
"title": "Descoperă funcționalitățile",
|
"title": "Descoperă funcționalitățile",
|
||||||
@@ -173,12 +173,13 @@
|
|||||||
"subscribe": "Abonează-te"
|
"subscribe": "Abonează-te"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"brand": "Ghid Biblic",
|
"brand": "Biblical Guide",
|
||||||
"description": "O platformă modernă pentru studiul Bibliei cu înțelegeri AI și suport comunitar.",
|
"description": "O platformă modernă pentru studiul Bibliei cu înțelegeri AI și suport comunitar.",
|
||||||
"quickLinks": {
|
"quickLinks": {
|
||||||
"title": "Linkuri Rapide",
|
"title": "Linkuri Rapide",
|
||||||
"about": "Despre",
|
"about": "Despre",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
|
"contact": "Contact",
|
||||||
"support": "Suport",
|
"support": "Suport",
|
||||||
"api": "Documentație API"
|
"api": "Documentație API"
|
||||||
},
|
},
|
||||||
@@ -192,7 +193,7 @@
|
|||||||
"social": {
|
"social": {
|
||||||
"title": "Urmărește-ne"
|
"title": "Urmărește-ne"
|
||||||
},
|
},
|
||||||
"copyright": "© 2024 Ghid Biblic - Făcut cu ❤️ și 🙏"
|
"copyright": "© 2024 Biblical Guide - Făcut cu ❤️ și 🙏"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
"subtitle": "Explorează Scriptura cu o interfață modernă și intuitivă",
|
"subtitle": "Explorează Scriptura cu o interfață modernă și intuitivă",
|
||||||
"selectBook": "Selectează cartea",
|
"selectBook": "Selectează cartea",
|
||||||
"selectChapter": "Selectează capitolul",
|
"selectChapter": "Selectează capitolul",
|
||||||
|
"version": "Versiunea",
|
||||||
"book": "Cartea",
|
"book": "Cartea",
|
||||||
"chapter": "Capitolul",
|
"chapter": "Capitolul",
|
||||||
"verse": "Versetul",
|
"verse": "Versetul",
|
||||||
@@ -482,14 +484,52 @@
|
|||||||
"next": "Următorul",
|
"next": "Următorul",
|
||||||
"previous": "Anterior"
|
"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": {
|
"seo": {
|
||||||
"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",
|
||||||
"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ă.",
|
"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ă",
|
"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.",
|
"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.",
|
"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-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/pdf-parse": "^1.1.5",
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
|
"tinymce": "^8.1.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
@@ -3201,6 +3203,25 @@
|
|||||||
"tailwindcss": "4.1.13"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -7365,6 +7386,12 @@
|
|||||||
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"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-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/pdf-parse": "^1.1.5",
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
|
"tinymce": "^8.1.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ model User {
|
|||||||
userPrayers UserPrayer[]
|
userPrayers UserPrayer[]
|
||||||
readingHistory ReadingHistory[]
|
readingHistory ReadingHistory[]
|
||||||
preferences UserPreference[]
|
preferences UserPreference[]
|
||||||
|
createdPages Page[] @relation("PageCreator")
|
||||||
|
updatedPages Page[] @relation("PageUpdater")
|
||||||
|
uploadedFiles MediaFile[]
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
}
|
}
|
||||||
@@ -52,6 +55,10 @@ model BibleVersion {
|
|||||||
abbreviation String // e.g., "KJV", "CORNILESCU", "NIV"
|
abbreviation String // e.g., "KJV", "CORNILESCU", "NIV"
|
||||||
language String // e.g., "en", "ro", "es"
|
language String // e.g., "en", "ro", "es"
|
||||||
description String?
|
description String?
|
||||||
|
country String?
|
||||||
|
englishTitle String?
|
||||||
|
flagImageUrl String?
|
||||||
|
zipFileUrl String?
|
||||||
isDefault Boolean @default(false)
|
isDefault Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -281,3 +288,63 @@ model UserPreference {
|
|||||||
|
|
||||||
@@unique([userId, key])
|
@@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