Add comprehensive page management system to admin dashboard
Features added: - Database schema for pages and media files with content types (Rich Text, HTML, Markdown) - Admin API routes for full page CRUD operations - Image upload functionality with file management - Rich text editor using TinyMCE with image insertion - Admin interface for creating/editing pages with SEO options - Dynamic navigation and footer integration - Public page display routes with proper SEO metadata - Support for featured images and content excerpts Admin features: - Create/edit/delete pages with rich content editor - Upload and manage images through media library - Configure pages to appear in navigation or footer - Set page status (Draft, Published, Archived) - SEO title and description management - Real-time preview of content changes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,14 @@ interface BibleChapter {
|
||||
verses: BibleVerse[]
|
||||
}
|
||||
|
||||
interface BibleVersion {
|
||||
id: string
|
||||
name: string
|
||||
abbreviation: string
|
||||
language: string
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
interface BibleBook {
|
||||
id: string
|
||||
versionId: string
|
||||
@@ -122,10 +130,13 @@ export default function BibleReaderNew() {
|
||||
|
||||
// Core state
|
||||
const [books, setBooks] = useState<BibleBook[]>([])
|
||||
const [versions, setVersions] = useState<BibleVersion[]>([])
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>('')
|
||||
const [selectedBook, setSelectedBook] = useState<string>('')
|
||||
const [selectedChapter, setSelectedChapter] = useState<number>(1)
|
||||
const [verses, setVerses] = useState<BibleVerse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [versionsLoading, setVersionsLoading] = useState(true)
|
||||
|
||||
// UI state
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
@@ -172,13 +183,29 @@ export default function BibleReaderNew() {
|
||||
console.error('Failed to parse preferences:', e)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load saved version preference
|
||||
const savedVersion = localStorage.getItem('selectedBibleVersion')
|
||||
if (savedVersion && versions.length > 0) {
|
||||
const version = versions.find(v => v.id === savedVersion)
|
||||
if (version) {
|
||||
setSelectedVersion(savedVersion)
|
||||
}
|
||||
}
|
||||
}, [versions])
|
||||
|
||||
// Save preferences to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bibleReaderPreferences', JSON.stringify(preferences))
|
||||
}, [preferences])
|
||||
|
||||
// Save selected version to localStorage
|
||||
useEffect(() => {
|
||||
if (selectedVersion) {
|
||||
localStorage.setItem('selectedBibleVersion', selectedVersion)
|
||||
}
|
||||
}, [selectedVersion])
|
||||
|
||||
// Scroll handler for show scroll to top button
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -189,29 +216,60 @@ export default function BibleReaderNew() {
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Fetch books
|
||||
// Fetch versions based on current locale
|
||||
useEffect(() => {
|
||||
fetch(`/api/bible/books?locale=${locale}`)
|
||||
setVersionsLoading(true)
|
||||
fetch(`/api/bible/versions?language=${locale}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setBooks(data.books || [])
|
||||
if (data.books && data.books.length > 0) {
|
||||
setSelectedBook(data.books[0].id)
|
||||
if (data.success && data.versions) {
|
||||
setVersions(data.versions)
|
||||
// Select default version or first available
|
||||
const defaultVersion = data.versions.find((v: BibleVersion) => v.isDefault) || data.versions[0]
|
||||
if (defaultVersion) {
|
||||
setSelectedVersion(defaultVersion.id)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
setVersionsLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching books:', err)
|
||||
setLoading(false)
|
||||
console.error('Error fetching versions:', err)
|
||||
setVersionsLoading(false)
|
||||
})
|
||||
}, [locale])
|
||||
|
||||
// Fetch books when version changes
|
||||
useEffect(() => {
|
||||
if (selectedVersion) {
|
||||
setLoading(true)
|
||||
fetch(`/api/bible/books?locale=${locale}&version=${selectedVersion}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setBooks(data.books || [])
|
||||
if (data.books && data.books.length > 0) {
|
||||
setSelectedBook(data.books[0].id)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching books:', err)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
}, [locale, selectedVersion])
|
||||
|
||||
// Handle URL parameters
|
||||
useEffect(() => {
|
||||
if (books.length > 0) {
|
||||
if (books.length > 0 && versions.length > 0) {
|
||||
const bookParam = searchParams.get('book')
|
||||
const chapterParam = searchParams.get('chapter')
|
||||
const verseParam = searchParams.get('verse')
|
||||
const versionParam = searchParams.get('version')
|
||||
|
||||
// Handle version parameter
|
||||
if (versionParam && versions.find(v => v.id === versionParam)) {
|
||||
setSelectedVersion(versionParam)
|
||||
}
|
||||
|
||||
if (bookParam) {
|
||||
const book = books.find(b => b.id === bookParam) || books.find(b => b.bookKey === bookParam)
|
||||
@@ -236,7 +294,7 @@ export default function BibleReaderNew() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [books, searchParams])
|
||||
}, [books, versions, searchParams])
|
||||
|
||||
// Fetch verses when book/chapter changes
|
||||
useEffect(() => {
|
||||
@@ -350,10 +408,13 @@ export default function BibleReaderNew() {
|
||||
const currentBook = books.find(book => book.id === selectedBook)
|
||||
const maxChapters = currentBook?.chapters?.length || 1
|
||||
|
||||
const updateUrl = (bookId: string, chapter: number) => {
|
||||
const updateUrl = (bookId: string, chapter: number, version?: string) => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('book', bookId)
|
||||
url.searchParams.set('chapter', chapter.toString())
|
||||
if (version) {
|
||||
url.searchParams.set('version', version)
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
@@ -368,7 +429,7 @@ export default function BibleReaderNew() {
|
||||
if (selectedChapter > 1) {
|
||||
const newChapter = selectedChapter - 1
|
||||
setSelectedChapter(newChapter)
|
||||
updateUrl(selectedBook, newChapter)
|
||||
updateUrl(selectedBook, newChapter, selectedVersion)
|
||||
} else {
|
||||
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
|
||||
if (currentBookIndex > 0) {
|
||||
@@ -376,7 +437,7 @@ export default function BibleReaderNew() {
|
||||
const lastChapter = previousBook.chapters?.length || 1
|
||||
setSelectedBook(previousBook.id)
|
||||
setSelectedChapter(lastChapter)
|
||||
updateUrl(previousBook.id, lastChapter)
|
||||
updateUrl(previousBook.id, lastChapter, selectedVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,14 +446,14 @@ export default function BibleReaderNew() {
|
||||
if (selectedChapter < maxChapters) {
|
||||
const newChapter = selectedChapter + 1
|
||||
setSelectedChapter(newChapter)
|
||||
updateUrl(selectedBook, newChapter)
|
||||
updateUrl(selectedBook, newChapter, selectedVersion)
|
||||
} else {
|
||||
const currentBookIndex = books.findIndex(book => book.id === selectedBook)
|
||||
if (currentBookIndex < books.length - 1) {
|
||||
const nextBook = books[currentBookIndex + 1]
|
||||
setSelectedBook(nextBook.id)
|
||||
setSelectedChapter(1)
|
||||
updateUrl(nextBook.id, 1)
|
||||
updateUrl(nextBook.id, 1, selectedVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,7 +554,7 @@ export default function BibleReaderNew() {
|
||||
}
|
||||
|
||||
const handleShare = () => {
|
||||
const url = `${window.location.origin}/${locale}/bible?book=${selectedBook}&chapter=${selectedChapter}`
|
||||
const url = `${window.location.origin}/${locale}/bible?book=${selectedBook}&chapter=${selectedChapter}&version=${selectedVersion}`
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopyFeedback({
|
||||
open: true,
|
||||
@@ -614,6 +675,33 @@ export default function BibleReaderNew() {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{/* Version Selection */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 180, md: 200 } }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('version')}</InputLabel>
|
||||
<Select
|
||||
value={selectedVersion}
|
||||
label={t('version')}
|
||||
onChange={(e) => {
|
||||
setSelectedVersion(e.target.value)
|
||||
// Reset to first book when version changes
|
||||
if (books.length > 0) {
|
||||
setSelectedBook(books[0].id)
|
||||
setSelectedChapter(1)
|
||||
updateUrl(books[0].id, 1, e.target.value)
|
||||
}
|
||||
}}
|
||||
disabled={versionsLoading}
|
||||
>
|
||||
{versions.map((version) => (
|
||||
<MenuItem key={version.id} value={version.id}>
|
||||
{version.abbreviation} - {version.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Books Selection */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 auto' }, minWidth: { sm: 200, md: 250 } }}>
|
||||
<FormControl fullWidth size="small">
|
||||
@@ -624,7 +712,7 @@ export default function BibleReaderNew() {
|
||||
onChange={(e) => {
|
||||
setSelectedBook(e.target.value)
|
||||
setSelectedChapter(1)
|
||||
updateUrl(e.target.value, 1)
|
||||
updateUrl(e.target.value, 1, selectedVersion)
|
||||
}}
|
||||
>
|
||||
{books.map((book) => (
|
||||
@@ -646,7 +734,7 @@ export default function BibleReaderNew() {
|
||||
onChange={(e) => {
|
||||
const newChapter = Number(e.target.value)
|
||||
setSelectedChapter(newChapter)
|
||||
updateUrl(selectedBook, newChapter)
|
||||
updateUrl(selectedBook, newChapter, selectedVersion)
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
|
||||
281
app/[locale]/contact/page.tsx
Normal file
281
app/[locale]/contact/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
import {
|
||||
Container,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Paper,
|
||||
useTheme,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Email,
|
||||
LocationOn,
|
||||
Send,
|
||||
ContactSupport,
|
||||
} from '@mui/icons-material'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Contact() {
|
||||
const theme = useTheme()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('contact')
|
||||
const locale = useLocale()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
const [showError, setShowError] = useState(false)
|
||||
|
||||
const handleInputChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: event.target.value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// Simulate form submission
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Here you would typically send the data to your API
|
||||
console.log('Form submitted:', formData)
|
||||
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
})
|
||||
setShowSuccess(true)
|
||||
} catch (error) {
|
||||
setShowError(true)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: <Email sx={{ fontSize: 30, color: 'primary.main' }} />,
|
||||
title: t('info.email.title'),
|
||||
content: t('info.email.content'),
|
||||
action: 'mailto:contact@biblical-guide.com'
|
||||
},
|
||||
{
|
||||
icon: <LocationOn sx={{ fontSize: 30, color: 'primary.main' }} />,
|
||||
title: t('info.address.title'),
|
||||
content: t('info.address.content'),
|
||||
action: null
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 4 }}>
|
||||
{/* Hero Section */}
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #009688 0%, #00796B 100%)',
|
||||
color: 'white',
|
||||
py: 8,
|
||||
mb: 6,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<ContactSupport sx={{ fontSize: 80, mb: 2, opacity: 0.9 }} />
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
{t('hero.title')}
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" sx={{ mb: 2, opacity: 0.9 }}>
|
||||
{t('hero.subtitle')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ opacity: 0.8, maxWidth: 600, mx: 'auto' }}>
|
||||
{t('hero.description')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{/* Contact Form */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 65%' } }}>
|
||||
<Card sx={{ height: 'fit-content' }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Typography variant="h4" component="h2" gutterBottom>
|
||||
{t('form.title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
{t('form.description')}
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label={t('form.fields.name')}
|
||||
value={formData.name}
|
||||
onChange={handleInputChange('name')}
|
||||
variant="outlined"
|
||||
sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)' } }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
type="email"
|
||||
label={t('form.fields.email')}
|
||||
value={formData.email}
|
||||
onChange={handleInputChange('email')}
|
||||
variant="outlined"
|
||||
sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)' } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label={t('form.fields.subject')}
|
||||
value={formData.subject}
|
||||
onChange={handleInputChange('subject')}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
multiline
|
||||
rows={6}
|
||||
label={t('form.fields.message')}
|
||||
value={formData.message}
|
||||
onChange={handleInputChange('message')}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={isSubmitting}
|
||||
startIcon={<Send />}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{isSubmitting ? t('form.submitting') : t('form.submit')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 35%' } }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Typography variant="h4" component="h2">
|
||||
{t('info.title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('info.description')}
|
||||
</Typography>
|
||||
|
||||
{contactInfo.map((info, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
sx={{
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
cursor: info.action ? 'pointer' : 'default',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
'&:hover': info.action ? {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 2
|
||||
} : {}
|
||||
}}
|
||||
onClick={() => info.action && window.open(info.action, '_self')}
|
||||
>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
{info.icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{info.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{info.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{/* FAQ Quick Link */}
|
||||
<Paper sx={{ p: 3, bgcolor: 'primary.light', color: 'white' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('faq.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3, opacity: 0.9 }}>
|
||||
{t('faq.description')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'white',
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
bgcolor: 'rgba(255,255,255,0.1)'
|
||||
}
|
||||
}}
|
||||
onClick={() => router.push(`/${locale}#faq`)}
|
||||
>
|
||||
{t('faq.viewFaq')}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
<Snackbar
|
||||
open={showSuccess}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setShowSuccess(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={() => setShowSuccess(false)} severity="success" sx={{ width: '100%' }}>
|
||||
{t('form.success')}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<Snackbar
|
||||
open={showError}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setShowError(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={() => setShowError(false)} severity="error" sx={{ width: '100%' }}>
|
||||
{t('form.error')}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { notFound } from 'next/navigation'
|
||||
import { MuiThemeProvider } from '@/components/providers/theme-provider'
|
||||
import { AuthProvider } from '@/components/auth/auth-provider'
|
||||
import { Navigation } from '@/components/layout/navigation'
|
||||
import { Footer } from '@/components/layout/footer'
|
||||
import FloatingChat from '@/components/chat/floating-chat'
|
||||
import { merriweather, lato } from '@/lib/fonts'
|
||||
|
||||
@@ -32,7 +33,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
title: t('ogTitle'),
|
||||
description: t('ogDescription'),
|
||||
url: currentUrl,
|
||||
siteName: locale === 'ro' ? 'Ghid Biblic' : 'Biblical Guide',
|
||||
siteName: 'Biblical Guide',
|
||||
locale: locale,
|
||||
type: 'website',
|
||||
images: [
|
||||
@@ -106,6 +107,7 @@ export default async function LocaleLayout({
|
||||
<AuthProvider>
|
||||
<Navigation />
|
||||
{children}
|
||||
<Footer />
|
||||
<FloatingChat />
|
||||
</AuthProvider>
|
||||
</MuiThemeProvider>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
TextField,
|
||||
Chip,
|
||||
Avatar,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
@@ -31,10 +30,6 @@ import {
|
||||
Bookmark,
|
||||
TrendingUp,
|
||||
QuestionAnswer,
|
||||
Facebook,
|
||||
Twitter,
|
||||
Instagram,
|
||||
YouTube,
|
||||
} from '@mui/icons-material'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
@@ -613,98 +608,6 @@ export default function Home() {
|
||||
</Container>
|
||||
</Paper>
|
||||
|
||||
{/* Enhanced Footer */}
|
||||
<Paper component="footer" sx={{ bgcolor: 'grey.900', color: 'white', py: 6 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'space-between', mb: 4 }}>
|
||||
{/* Brand */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{t('footer.brand')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="grey.400" sx={{ maxWidth: 300 }}>
|
||||
{tSeo('footer')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Quick Links */}
|
||||
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('footer.quickLinks.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.about')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.blog')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.support')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.api')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legal */}
|
||||
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('footer.legal.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.terms')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.privacy')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.cookies')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.gdpr')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Social */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('footer.social.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Facebook />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Twitter />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Instagram />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<YouTube />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ bgcolor: 'grey.700', mb: 3 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Typography variant="body2" color="grey.400">
|
||||
© {getCurrentYear()} {locale === 'ro' ? 'Ghid Biblic - Făcut cu ❤️ și 🙏' : 'Biblical Guide - Made with ❤️ and 🙏'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label="🇷🇴 Română" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
|
||||
<Chip label="🇺🇸 English" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
|
||||
<Chip label="+20 more" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
309
app/[locale]/pages/[slug]/page.tsx
Normal file
309
app/[locale]/pages/[slug]/page.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Paper,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
Avatar
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Home as HomeIcon,
|
||||
Article as ArticleIcon,
|
||||
CalendarToday as DateIcon
|
||||
} from '@mui/icons-material'
|
||||
|
||||
interface PageData {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
content: string
|
||||
contentType: 'RICH_TEXT' | 'HTML' | 'MARKDOWN'
|
||||
excerpt?: string
|
||||
featuredImage?: string
|
||||
seoTitle?: string
|
||||
seoDescription?: string
|
||||
publishedAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
locale: string
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
async function getPageData(slug: string): Promise<PageData | null> {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3010'}/api/pages/${slug}`, {
|
||||
next: { revalidate: 300 } // Revalidate every 5 minutes
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.success ? data.data : null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch page:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const page = await getPageData(params.slug)
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
title: 'Page Not Found',
|
||||
description: 'The requested page could not be found.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: page.seoTitle || page.title,
|
||||
description: page.seoDescription || page.excerpt || `Read ${page.title} on Biblical Guide`,
|
||||
openGraph: {
|
||||
title: page.seoTitle || page.title,
|
||||
description: page.seoDescription || page.excerpt,
|
||||
type: 'article',
|
||||
publishedTime: page.publishedAt,
|
||||
modifiedTime: page.updatedAt,
|
||||
...(page.featuredImage && {
|
||||
images: [{
|
||||
url: page.featuredImage,
|
||||
alt: page.title
|
||||
}]
|
||||
})
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: page.seoTitle || page.title,
|
||||
description: page.seoDescription || page.excerpt,
|
||||
...(page.featuredImage && {
|
||||
images: [page.featuredImage]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent(content: string, contentType: string) {
|
||||
switch (contentType) {
|
||||
case 'HTML':
|
||||
return (
|
||||
<Box
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
sx={{
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: 1
|
||||
},
|
||||
'& p': {
|
||||
marginBottom: 2
|
||||
},
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
marginTop: 3,
|
||||
marginBottom: 2
|
||||
},
|
||||
'& ul, & ol': {
|
||||
paddingLeft: 3
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'primary.main',
|
||||
paddingLeft: 2,
|
||||
marginLeft: 0,
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: 'grey.50',
|
||||
padding: 2,
|
||||
borderRadius: 1
|
||||
},
|
||||
'& code': {
|
||||
backgroundColor: 'grey.100',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace'
|
||||
},
|
||||
'& pre': {
|
||||
backgroundColor: 'grey.100',
|
||||
padding: 2,
|
||||
borderRadius: 1,
|
||||
overflow: 'auto'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'MARKDOWN':
|
||||
// For now, render as plain text. In the future, you could add a markdown parser
|
||||
return (
|
||||
<Typography component="div" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{content}
|
||||
</Typography>
|
||||
)
|
||||
|
||||
default: // RICH_TEXT
|
||||
return (
|
||||
<Box
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
sx={{
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: 1
|
||||
},
|
||||
'& p': {
|
||||
marginBottom: 2
|
||||
},
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
marginTop: 3,
|
||||
marginBottom: 2
|
||||
},
|
||||
'& ul, & ol': {
|
||||
paddingLeft: 3
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'primary.main',
|
||||
paddingLeft: 2,
|
||||
marginLeft: 0,
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: 'grey.50',
|
||||
padding: 2,
|
||||
borderRadius: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PageView({ params }: PageProps) {
|
||||
const page = await getPageData(params.slug)
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(params.locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
|
||||
<Link
|
||||
underline="hover"
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
color="inherit"
|
||||
href={`/${params.locale}`}
|
||||
>
|
||||
<HomeIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Home
|
||||
</Link>
|
||||
<Typography
|
||||
color="text.primary"
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ArticleIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
{page.title}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Paper sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{/* Featured Image */}
|
||||
{page.featuredImage && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<img
|
||||
src={page.featuredImage}
|
||||
alt={page.title}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '400px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: 'text.primary',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
{page.title}
|
||||
</Typography>
|
||||
|
||||
{/* Excerpt */}
|
||||
{page.excerpt && (
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 400,
|
||||
mb: 3,
|
||||
fontStyle: 'italic'
|
||||
}}
|
||||
>
|
||||
{page.excerpt}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Meta Information */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 4,
|
||||
pb: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<DateIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Published {formatDate(page.publishedAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{page.updatedAt !== page.publishedAt && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
• Updated {formatDate(page.updatedAt)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{
|
||||
typography: 'body1',
|
||||
lineHeight: 1.7,
|
||||
'& > *:first-of-type': { marginTop: 0 },
|
||||
'& > *:last-child': { marginBottom: 0 }
|
||||
}}>
|
||||
{renderContent(page.content, page.contentType)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
357
app/admin/pages/page.tsx
Normal file
357
app/admin/pages/page.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
IconButton,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Visibility as ViewIcon,
|
||||
Home as HomeIcon,
|
||||
ArticleOutlined as PagesIcon,
|
||||
Public as PublicIcon,
|
||||
Navigation as NavigationIcon,
|
||||
Foundation as FooterIcon
|
||||
} from '@mui/icons-material';
|
||||
import { DataGrid, GridColDef, GridActionsCellItem } from '@mui/x-data-grid';
|
||||
import { PageEditor } from '@/components/admin/pages/page-editor';
|
||||
|
||||
interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
showInNavigation: boolean;
|
||||
showInFooter: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
creator: { name: string; email: string };
|
||||
updater: { name: string; email: string };
|
||||
}
|
||||
|
||||
export default function PagesManagement() {
|
||||
const [pages, setPages] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPage, setSelectedPage] = useState<Page | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pageToDelete, setPageToDelete] = useState<Page | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPages();
|
||||
}, [filterStatus, searchQuery]);
|
||||
|
||||
const fetchPages = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const params = new URLSearchParams();
|
||||
if (filterStatus !== 'all') params.append('status', filterStatus);
|
||||
if (searchQuery) params.append('search', searchQuery);
|
||||
|
||||
const response = await fetch(`/api/admin/pages?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pages');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPages(data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching pages:', error);
|
||||
setError('Failed to load pages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePage = () => {
|
||||
setSelectedPage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPage = (page: Page) => {
|
||||
setSelectedPage(page);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeletePage = async (page: Page) => {
|
||||
setPageToDelete(page);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeletePage = async () => {
|
||||
if (!pageToDelete) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`/api/admin/pages/${pageToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete page');
|
||||
}
|
||||
|
||||
setSuccess('Page deleted successfully');
|
||||
fetchPages();
|
||||
setDeleteDialogOpen(false);
|
||||
setPageToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
setError('Failed to delete page');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSaved = () => {
|
||||
setSuccess('Page saved successfully');
|
||||
setEditorOpen(false);
|
||||
fetchPages();
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PUBLISHED': return 'success';
|
||||
case 'DRAFT': return 'warning';
|
||||
case 'ARCHIVED': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
width: 300,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{params.value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
/{params.row.slug}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Chip
|
||||
label={params.value}
|
||||
color={getStatusColor(params.value) as any}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'showInNavigation',
|
||||
headerName: 'Navigation',
|
||||
width: 100,
|
||||
renderCell: (params) => (
|
||||
params.value ? <NavigationIcon color="primary" /> : null
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'showInFooter',
|
||||
headerName: 'Footer',
|
||||
width: 80,
|
||||
renderCell: (params) => (
|
||||
params.value ? <FooterIcon color="primary" /> : null
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'creator',
|
||||
headerName: 'Created By',
|
||||
width: 150,
|
||||
renderCell: (params) => params.value?.name || params.value?.email
|
||||
},
|
||||
{
|
||||
field: 'updatedAt',
|
||||
headerName: 'Last Updated',
|
||||
width: 150,
|
||||
renderCell: (params) => new Date(params.value).toLocaleDateString()
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 120,
|
||||
getActions: (params) => [
|
||||
<GridActionsCellItem
|
||||
key="edit"
|
||||
icon={<EditIcon />}
|
||||
label="Edit"
|
||||
onClick={() => handleEditPage(params.row)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
key="view"
|
||||
icon={<ViewIcon />}
|
||||
label="View"
|
||||
onClick={() => window.open(`/pages/${params.row.slug}`, '_blank')}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
key="delete"
|
||||
icon={<DeleteIcon />}
|
||||
label="Delete"
|
||||
onClick={() => handleDeletePage(params.row)}
|
||||
/>
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
|
||||
<Link
|
||||
underline="hover"
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
color="inherit"
|
||||
href="/admin"
|
||||
>
|
||||
<HomeIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Admin
|
||||
</Link>
|
||||
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PagesIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Pages
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Page Management
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Create and manage website pages for navigation and footer
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreatePage}
|
||||
sx={{ height: 'fit-content' }}
|
||||
>
|
||||
New Page
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess(null)} sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Search pages"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={filterStatus}
|
||||
label="Status"
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All Status</MenuItem>
|
||||
<MenuItem value="published">Published</MenuItem>
|
||||
<MenuItem value="draft">Draft</MenuItem>
|
||||
<MenuItem value="archived">Archived</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pages Table */}
|
||||
<Card>
|
||||
<DataGrid
|
||||
rows={pages}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
autoHeight
|
||||
pageSize={25}
|
||||
disableSelectionOnClick
|
||||
sx={{
|
||||
'& .MuiDataGrid-cell': {
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Page Editor Dialog */}
|
||||
<PageEditor
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
page={selectedPage}
|
||||
onSave={handlePageSaved}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete Page</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={confirmDeletePage} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
137
app/api/admin/media/route.ts
Normal file
137
app/api/admin/media/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminAuth } from '@/lib/admin-auth';
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), 'public', 'uploads');
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const adminUser = await verifyAdminAuth(request);
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const alt = formData.get('alt') as string;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid file type. Only images are allowed.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'File too large. Maximum size is 5MB.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure upload directory exists
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const extension = path.extname(file.name);
|
||||
const baseFilename = file.name.replace(extension, '').replace(/[^a-zA-Z0-9]/g, '-');
|
||||
const filename = `${timestamp}-${baseFilename}${extension}`;
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
// Write file to disk
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Save to database
|
||||
const mediaFile = await prisma.mediaFile.create({
|
||||
data: {
|
||||
filename,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
path: filePath,
|
||||
url: `/uploads/${filename}`,
|
||||
alt: alt || null,
|
||||
uploadedBy: adminUser.id
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: mediaFile
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to upload file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const adminUser = await verifyAdminAuth(request);
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const type = searchParams.get('type');
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (type) {
|
||||
where.mimeType = { startsWith: type };
|
||||
}
|
||||
|
||||
const [files, total] = await Promise.all([
|
||||
prisma.mediaFile.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
uploader: { select: { name: true, email: true } }
|
||||
}
|
||||
}),
|
||||
prisma.mediaFile.count({ where })
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: files,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching media files:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch media files' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
174
app/api/admin/pages/[id]/route.ts
Normal file
174
app/api/admin/pages/[id]/route.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminAuth } from '@/lib/admin-auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const adminUser = await verifyAdminAuth(request);
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
creator: { select: { name: true, email: true } },
|
||||
updater: { select: { name: true, email: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Page not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: page
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching page:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch page' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const adminUser = await verifyAdminAuth(request);
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
contentType,
|
||||
excerpt,
|
||||
featuredImage,
|
||||
seoTitle,
|
||||
seoDescription,
|
||||
status,
|
||||
showInNavigation,
|
||||
showInFooter,
|
||||
navigationOrder,
|
||||
footerOrder
|
||||
} = body;
|
||||
|
||||
// Check if page exists
|
||||
const existingPage = await prisma.page.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!existingPage) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Page not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if slug is being changed and conflicts with another page
|
||||
if (slug && slug !== existingPage.slug) {
|
||||
const conflictingPage = await prisma.page.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (conflictingPage && conflictingPage.id !== params.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'A page with this slug already exists' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPage = await prisma.page.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
contentType,
|
||||
excerpt,
|
||||
featuredImage,
|
||||
seoTitle,
|
||||
seoDescription,
|
||||
status,
|
||||
showInNavigation,
|
||||
showInFooter,
|
||||
navigationOrder,
|
||||
footerOrder,
|
||||
updatedBy: adminUser.id,
|
||||
publishedAt: status === 'PUBLISHED' && !existingPage.publishedAt
|
||||
? new Date()
|
||||
: status === 'PUBLISHED'
|
||||
? existingPage.publishedAt
|
||||
: null
|
||||
},
|
||||
include: {
|
||||
creator: { select: { name: true, email: true } },
|
||||
updater: { select: { name: true, email: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedPage
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating page:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update page' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const adminUser = await verifyAdminAuth(request);
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Page not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.page.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Page deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete page' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
144
app/api/admin/pages/route.ts
Normal file
144
app/api/admin/pages/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminAuth } from '@/lib/admin-auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const adminUser = await verifyAdminAuth(request);
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (status && status !== 'all') {
|
||||
where.status = status.toUpperCase();
|
||||
}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ content: { contains: search, mode: 'insensitive' } },
|
||||
{ slug: { contains: search, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [pages, total] = await Promise.all([
|
||||
prisma.page.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
creator: { select: { name: true, email: true } },
|
||||
updater: { select: { name: true, email: true } }
|
||||
}
|
||||
}),
|
||||
prisma.page.count({ where })
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: pages,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching pages:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch pages' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const adminUser = await verifyAdminAuth(request);
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
contentType = 'RICH_TEXT',
|
||||
excerpt,
|
||||
featuredImage,
|
||||
seoTitle,
|
||||
seoDescription,
|
||||
status = 'DRAFT',
|
||||
showInNavigation = false,
|
||||
showInFooter = false,
|
||||
navigationOrder,
|
||||
footerOrder
|
||||
} = body;
|
||||
|
||||
if (!title || !slug || !content) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Title, slug, and content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
const existingPage = await prisma.page.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (existingPage) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'A page with this slug already exists' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const page = await prisma.page.create({
|
||||
data: {
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
contentType,
|
||||
excerpt,
|
||||
featuredImage,
|
||||
seoTitle,
|
||||
seoDescription,
|
||||
status,
|
||||
showInNavigation,
|
||||
showInFooter,
|
||||
navigationOrder,
|
||||
footerOrder,
|
||||
createdBy: adminUser.id,
|
||||
updatedBy: adminUser.id,
|
||||
publishedAt: status === 'PUBLISHED' ? new Date() : null
|
||||
},
|
||||
include: {
|
||||
creator: { select: { name: true, email: true } },
|
||||
updater: { select: { name: true, email: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: page
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating page:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create page' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,17 @@ export async function GET(request: Request) {
|
||||
console.log('Books API called')
|
||||
const { searchParams } = new URL(request.url)
|
||||
const locale = searchParams.get('locale') || 'ro'
|
||||
const versionAbbr = searchParams.get('version') // Optional specific version
|
||||
console.log('Locale:', locale, 'Version:', versionAbbr)
|
||||
const versionId = searchParams.get('version') // Optional specific version ID
|
||||
console.log('Locale:', locale, 'Version ID:', versionId)
|
||||
|
||||
// Get the appropriate Bible version
|
||||
let bibleVersion
|
||||
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
|
||||
if (versionAbbr) {
|
||||
if (versionId) {
|
||||
// Use specific version if provided
|
||||
bibleVersion = await prisma.bibleVersion.findFirst({
|
||||
where: {
|
||||
abbreviation: versionAbbr,
|
||||
id: versionId,
|
||||
language: { in: langCandidates }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,9 +9,10 @@ export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const bookId = searchParams.get('book') || ''
|
||||
const chapterNum = parseInt(searchParams.get('chapter') || '1')
|
||||
const versionId = searchParams.get('version') || ''
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = CacheManager.getChapterKey(bookId, chapterNum)
|
||||
// Check cache first (include version in cache key)
|
||||
const cacheKey = CacheManager.getChapterKey(bookId, chapterNum, versionId)
|
||||
const cachedChapter = await CacheManager.get(cacheKey)
|
||||
|
||||
if (cachedChapter) {
|
||||
@@ -25,7 +26,8 @@ export async function GET(request: Request) {
|
||||
const chapter = await prisma.bibleChapter.findFirst({
|
||||
where: {
|
||||
bookId,
|
||||
chapterNum
|
||||
chapterNum,
|
||||
book: versionId ? { versionId } : undefined
|
||||
},
|
||||
include: {
|
||||
verses: {
|
||||
@@ -33,7 +35,11 @@ export async function GET(request: Request) {
|
||||
verseNum: 'asc'
|
||||
}
|
||||
},
|
||||
book: true
|
||||
book: {
|
||||
include: {
|
||||
version: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
47
app/api/pages/[slug]/route.ts
Normal file
47
app/api/pages/[slug]/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
try {
|
||||
const page = await prisma.page.findUnique({
|
||||
where: {
|
||||
slug: params.slug,
|
||||
status: 'PUBLISHED'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
content: true,
|
||||
contentType: true,
|
||||
excerpt: true,
|
||||
featuredImage: true,
|
||||
seoTitle: true,
|
||||
seoDescription: true,
|
||||
publishedAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Page not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: page
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching page by slug:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch page' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/api/pages/route.ts
Normal file
53
app/api/pages/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const location = searchParams.get('location'); // 'navigation', 'footer', or 'all'
|
||||
|
||||
const where: any = {
|
||||
status: 'PUBLISHED'
|
||||
};
|
||||
|
||||
if (location === 'navigation') {
|
||||
where.showInNavigation = true;
|
||||
} else if (location === 'footer') {
|
||||
where.showInFooter = true;
|
||||
}
|
||||
|
||||
const orderBy: any = [];
|
||||
if (location === 'navigation') {
|
||||
orderBy.push({ navigationOrder: 'asc' });
|
||||
} else if (location === 'footer') {
|
||||
orderBy.push({ footerOrder: 'asc' });
|
||||
}
|
||||
orderBy.push({ title: 'asc' });
|
||||
|
||||
const pages = await prisma.page.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
excerpt: true,
|
||||
showInNavigation: true,
|
||||
showInFooter: true,
|
||||
navigationOrder: true,
|
||||
footerOrder: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: pages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching public pages:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch pages' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user