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

@@ -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' },

View 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>
);
}

View 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}
/>
</>
);
}