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:
@@ -31,7 +31,8 @@ import {
|
||||
Logout,
|
||||
AccountCircle,
|
||||
AdminPanelSettings,
|
||||
Launch as LaunchIcon
|
||||
Launch as LaunchIcon,
|
||||
Article as PageIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
@@ -49,6 +50,7 @@ const drawerWidth = 280;
|
||||
const menuItems = [
|
||||
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
|
||||
{ text: 'Users', icon: People, href: '/admin/users' },
|
||||
{ text: 'Pages', icon: PageIcon, href: '/admin/pages' },
|
||||
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
||||
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
||||
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
||||
|
||||
329
components/admin/pages/image-upload.tsx
Normal file
329
components/admin/pages/image-upload.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Paper,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
Tabs,
|
||||
Tab
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload as UploadIcon,
|
||||
Image as ImageIcon,
|
||||
InsertPhoto as InsertIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface MediaFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
url: string;
|
||||
alt: string | null;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ImageUploadProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImageSelect: (imageUrl: string) => void;
|
||||
}
|
||||
|
||||
export function ImageUpload({ open, onClose, onImageSelect }: ImageUploadProps) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||||
const [loadingMedia, setLoadingMedia] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchMediaFiles = async () => {
|
||||
setLoadingMedia(true);
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch('/api/admin/media?type=image', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch media files');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setMediaFiles(data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media files:', error);
|
||||
setError('Failed to load media files');
|
||||
} finally {
|
||||
setLoadingMedia(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB limit)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch('/api/admin/media', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Add the new file to the media files list
|
||||
setMediaFiles(prev => [result.data, ...prev]);
|
||||
|
||||
// Auto-select the uploaded image
|
||||
onImageSelect(result.data.url);
|
||||
|
||||
setUploadProgress(100);
|
||||
|
||||
// Reset form
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
setError(error instanceof Error ? error.message : 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setTimeout(() => setUploadProgress(0), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTab(newValue);
|
||||
if (newValue === 1 && mediaFiles.length === 0) {
|
||||
fetchMediaFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleUrlSubmit = () => {
|
||||
if (urlInput.trim()) {
|
||||
onImageSelect(urlInput.trim());
|
||||
setUrlInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Insert Image</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs value={tab} onChange={handleTabChange}>
|
||||
<Tab label="Upload New" />
|
||||
<Tab label="Media Library" />
|
||||
<Tab label="From URL" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Upload Tab */}
|
||||
{tab === 0 && (
|
||||
<Box>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '2px dashed #ccc',
|
||||
bgcolor: 'grey.50',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
bgcolor: 'grey.100'
|
||||
}
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<UploadIcon sx={{ fontSize: 48, color: 'grey.500', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Click to upload or drag and drop
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
PNG, JPG, GIF up to 5MB
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{uploading && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Uploading...
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Media Library Tab */}
|
||||
{tab === 1 && (
|
||||
<Box>
|
||||
{loadingMedia ? (
|
||||
<Typography>Loading media files...</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{mediaFiles.length === 0 ? (
|
||||
<Grid item xs={12}>
|
||||
<Typography color="text.secondary" textAlign="center">
|
||||
No images found. Upload some images first.
|
||||
</Typography>
|
||||
</Grid>
|
||||
) : (
|
||||
mediaFiles.map((file) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={file.id}>
|
||||
<Card>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="140"
|
||||
image={file.url}
|
||||
alt={file.alt || file.originalName}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<CardContent sx={{ p: 1 }}>
|
||||
<Typography variant="caption" display="block" noWrap>
|
||||
{file.originalName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatFileSize(file.size)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions sx={{ pt: 0 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<InsertIcon />}
|
||||
onClick={() => onImageSelect(file.url)}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* URL Tab */}
|
||||
{tab === 2 && (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Image URL"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleUrlSubmit}
|
||||
disabled={!urlInput.trim()}
|
||||
>
|
||||
Insert Image
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{urlInput && (
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Preview:
|
||||
</Typography>
|
||||
<img
|
||||
src={urlInput}
|
||||
alt="Preview"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '200px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
471
components/admin/pages/page-editor.tsx
Normal file
471
components/admin/pages/page-editor.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Box,
|
||||
Typography,
|
||||
Tab,
|
||||
Tabs,
|
||||
Alert,
|
||||
Grid,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { ImageUpload } from './image-upload';
|
||||
|
||||
interface Page {
|
||||
id?: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
contentType: 'RICH_TEXT' | 'HTML' | 'MARKDOWN';
|
||||
excerpt?: string;
|
||||
featuredImage?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
showInNavigation: boolean;
|
||||
showInFooter: boolean;
|
||||
navigationOrder?: number;
|
||||
footerOrder?: number;
|
||||
}
|
||||
|
||||
interface PageEditorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
page?: Page | null;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
||||
const [formData, setFormData] = useState<Page>({
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
contentType: 'RICH_TEXT',
|
||||
excerpt: '',
|
||||
featuredImage: '',
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
status: 'DRAFT',
|
||||
showInNavigation: false,
|
||||
showInFooter: false,
|
||||
navigationOrder: undefined,
|
||||
footerOrder: undefined
|
||||
});
|
||||
|
||||
const [contentTab, setContentTab] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [imageUploadOpen, setImageUploadOpen] = useState(false);
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (page) {
|
||||
setFormData({
|
||||
...page,
|
||||
excerpt: page.excerpt || '',
|
||||
featuredImage: page.featuredImage || '',
|
||||
seoTitle: page.seoTitle || '',
|
||||
seoDescription: page.seoDescription || ''
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
contentType: 'RICH_TEXT',
|
||||
excerpt: '',
|
||||
featuredImage: '',
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
status: 'DRAFT',
|
||||
showInNavigation: false,
|
||||
showInFooter: false,
|
||||
navigationOrder: undefined,
|
||||
footerOrder: undefined
|
||||
});
|
||||
}
|
||||
setError(null);
|
||||
}, [page, open]);
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug: prev.slug || generateSlug(title)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.title || !formData.slug || !formData.content) {
|
||||
setError('Title, slug, and content are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = page ? `/api/admin/pages/${page.id}` : '/api/admin/pages';
|
||||
const method = page ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to save page');
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (error) {
|
||||
console.error('Error saving page:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to save page');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageInsert = (imageUrl: string) => {
|
||||
if (formData.contentType === 'RICH_TEXT' && editorRef.current) {
|
||||
const editor = editorRef.current.getEditor();
|
||||
editor.insertContent(`<img src="${imageUrl}" alt="" style="max-width: 100%; height: auto;" />`);
|
||||
} else if (formData.contentType === 'HTML') {
|
||||
const imageTag = `<img src="${imageUrl}" alt="" style="max-width: 100%; height: auto;" />`;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
content: prev.content + imageTag
|
||||
}));
|
||||
}
|
||||
setImageUploadOpen(false);
|
||||
};
|
||||
|
||||
const renderContentEditor = () => {
|
||||
switch (formData.contentType) {
|
||||
case 'RICH_TEXT':
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2">Content</Typography>
|
||||
<Button size="small" onClick={() => setImageUploadOpen(true)}>
|
||||
Insert Image
|
||||
</Button>
|
||||
</Box>
|
||||
<Editor
|
||||
onInit={(evt, editor) => editorRef.current = { getEditor: () => editor }}
|
||||
value={formData.content}
|
||||
onEditorChange={(content) => setFormData(prev => ({ ...prev, content }))}
|
||||
init={{
|
||||
height: 400,
|
||||
menubar: true,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
||||
'insertdatetime', 'media', 'table', 'help', 'wordcount'
|
||||
],
|
||||
toolbar: 'undo redo | blocks | ' +
|
||||
'bold italic forecolor | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
||||
'removeformat | link image | code | help',
|
||||
content_style: 'body { font-family: Arial, Helvetica, sans-serif; font-size: 14px }',
|
||||
images_upload_handler: (blobInfo: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', blobInfo.blob(), blobInfo.filename());
|
||||
|
||||
const token = localStorage.getItem('authToken');
|
||||
fetch('/api/admin/media', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
resolve(result.data.url);
|
||||
} else {
|
||||
reject(result.error || 'Upload failed');
|
||||
}
|
||||
})
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'HTML':
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2">HTML Content</Typography>
|
||||
<Button size="small" onClick={() => setImageUploadOpen(true)}>
|
||||
Insert Image
|
||||
</Button>
|
||||
</Box>
|
||||
<TextField
|
||||
multiline
|
||||
rows={20}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'MARKDOWN':
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Markdown Content</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={20}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>
|
||||
{page ? 'Edit Page' : 'Create New Page'}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={contentTab} onChange={(_, newValue) => setContentTab(newValue)}>
|
||||
<Tab label="Content" />
|
||||
<Tab label="Settings" />
|
||||
<Tab label="SEO" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Content Tab */}
|
||||
{contentTab === 0 && (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Page Title"
|
||||
fullWidth
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Content Type</InputLabel>
|
||||
<Select
|
||||
value={formData.contentType}
|
||||
label="Content Type"
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, contentType: e.target.value as any }))}
|
||||
>
|
||||
<MenuItem value="RICH_TEXT">Rich Text</MenuItem>
|
||||
<MenuItem value="HTML">HTML</MenuItem>
|
||||
<MenuItem value="MARKDOWN">Markdown</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TextField
|
||||
label="URL Slug"
|
||||
fullWidth
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value }))}
|
||||
helperText="This will be the URL path for your page"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Excerpt"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={formData.excerpt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
|
||||
helperText="Brief description (optional)"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
|
||||
{renderContentEditor()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Settings Tab */}
|
||||
{contentTab === 1 && (
|
||||
<Box>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Publication</Typography>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
label="Status"
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as any }))}
|
||||
>
|
||||
<MenuItem value="DRAFT">Draft</MenuItem>
|
||||
<MenuItem value="PUBLISHED">Published</MenuItem>
|
||||
<MenuItem value="ARCHIVED">Archived</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Featured Image URL"
|
||||
fullWidth
|
||||
value={formData.featuredImage}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, featuredImage: e.target.value }))}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Display Options</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.showInNavigation}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, showInNavigation: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Show in Navigation"
|
||||
/>
|
||||
|
||||
{formData.showInNavigation && (
|
||||
<TextField
|
||||
label="Navigation Order"
|
||||
type="number"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.navigationOrder || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, navigationOrder: parseInt(e.target.value) || undefined }))}
|
||||
sx={{ mt: 1, mb: 2 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.showInFooter}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, showInFooter: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Show in Footer"
|
||||
/>
|
||||
|
||||
{formData.showInFooter && (
|
||||
<TextField
|
||||
label="Footer Order"
|
||||
type="number"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.footerOrder || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, footerOrder: parseInt(e.target.value) || undefined }))}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* SEO Tab */}
|
||||
{contentTab === 2 && (
|
||||
<Box>
|
||||
<TextField
|
||||
label="SEO Title"
|
||||
fullWidth
|
||||
value={formData.seoTitle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, seoTitle: e.target.value }))}
|
||||
helperText="Optimize for search engines (leave empty to use page title)"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="SEO Description"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={formData.seoDescription}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, seoDescription: e.target.value }))}
|
||||
helperText="Meta description for search engines (150-160 characters recommended)"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" disabled={loading}>
|
||||
{loading ? 'Saving...' : page ? 'Update Page' : 'Create Page'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<ImageUpload
|
||||
open={imageUploadOpen}
|
||||
onClose={() => setImageUploadOpen(false)}
|
||||
onImageSelect={handleImageInsert}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
components/layout/footer.tsx
Normal file
167
components/layout/footer.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Paper,
|
||||
Container,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
Chip,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Facebook,
|
||||
Twitter,
|
||||
Instagram,
|
||||
YouTube,
|
||||
} from '@mui/icons-material'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
|
||||
interface DynamicPage {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
showInFooter: boolean
|
||||
footerOrder?: number
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
|
||||
const router = useRouter()
|
||||
const t = useTranslations('home')
|
||||
const tSeo = useTranslations('seo')
|
||||
const locale = useLocale()
|
||||
|
||||
useEffect(() => {
|
||||
fetchDynamicPages()
|
||||
}, [])
|
||||
|
||||
const fetchDynamicPages = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pages?location=footer')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setDynamicPages(data.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dynamic pages:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentYear = () => {
|
||||
return new Date().getFullYear()
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper component="footer" sx={{ bgcolor: 'grey.900', color: 'white', py: 6 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'space-between', mb: 4 }}>
|
||||
{/* Brand */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{t('footer.brand')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="grey.400" sx={{ maxWidth: 300 }}>
|
||||
{tSeo('footer')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Quick Links */}
|
||||
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('footer.quickLinks.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.about')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.blog')}
|
||||
</Button>
|
||||
<Button
|
||||
color="inherit"
|
||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||
onClick={() => router.push(`/${locale}/contact`)}
|
||||
>
|
||||
{t('footer.quickLinks.contact')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.support')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.api')}
|
||||
</Button>
|
||||
{dynamicPages.map((page) => (
|
||||
<Button
|
||||
key={page.id}
|
||||
color="inherit"
|
||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
|
||||
>
|
||||
{page.title}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legal */}
|
||||
<Box sx={{ flex: { xs: '1 1 50%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('footer.legal.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.terms')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.privacy')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.cookies')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.gdpr')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Social */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 auto' } }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('footer.social.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Facebook />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Twitter />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Instagram />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<YouTube />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ bgcolor: 'grey.700', mb: 3 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Typography variant="body2" color="grey.400">
|
||||
© {getCurrentYear()} Biblical Guide - {locale === 'ro' ? 'Făcut cu ❤️ și 🙏' : 'Made with ❤️ and 🙏'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label="🇷🇴 Română" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
|
||||
<Chip label="🇺🇸 English" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
|
||||
<Chip label="+20 more" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'grey.600' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
@@ -38,10 +38,19 @@ import { useTranslations, useLocale } from 'next-intl'
|
||||
import { LanguageSwitcher } from './language-switcher'
|
||||
import { useAuth } from '@/hooks/use-auth'
|
||||
|
||||
interface DynamicPage {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
showInNavigation: boolean
|
||||
navigationOrder?: number
|
||||
}
|
||||
|
||||
export function Navigation() {
|
||||
const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null)
|
||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
|
||||
const router = useRouter()
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
@@ -49,13 +58,37 @@ export function Navigation() {
|
||||
const locale = useLocale()
|
||||
const { user, isAuthenticated, logout } = useAuth()
|
||||
|
||||
const pages = [
|
||||
useEffect(() => {
|
||||
fetchDynamicPages()
|
||||
}, [])
|
||||
|
||||
const fetchDynamicPages = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pages?location=navigation')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setDynamicPages(data.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dynamic pages:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const basePages = [
|
||||
{ name: t('home'), path: '/', icon: <Home /> },
|
||||
{ name: t('bible'), path: '/bible', icon: <MenuBook /> },
|
||||
{ name: t('prayers'), path: '/prayers', icon: <Prayer /> },
|
||||
{ name: t('search'), path: '/search', icon: <Search /> },
|
||||
]
|
||||
|
||||
const dynamicNavPages = dynamicPages.map(page => ({
|
||||
name: page.title,
|
||||
path: `/pages/${page.slug}`,
|
||||
icon: null
|
||||
}))
|
||||
|
||||
const pages = [...basePages, ...dynamicNavPages]
|
||||
|
||||
const authenticatedPages = [
|
||||
...pages,
|
||||
{ name: t('bookmarks'), path: '/bookmarks', icon: <Bookmark /> },
|
||||
|
||||
@@ -86,7 +86,7 @@ export function Navigation() {
|
||||
onClick={() => router.push('/')}
|
||||
className="text-xl font-bold text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
Ghid Biblic
|
||||
Biblical Guide
|
||||
</button>
|
||||
|
||||
<div className="hidden md:flex space-x-4">
|
||||
|
||||
Reference in New Issue
Block a user