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:
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user