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>
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
'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}
|
|
/>
|
|
</>
|
|
);
|
|
} |