Add comprehensive page management system to admin dashboard

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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