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:
357
app/admin/pages/page.tsx
Normal file
357
app/admin/pages/page.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
IconButton,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Visibility as ViewIcon,
|
||||
Home as HomeIcon,
|
||||
ArticleOutlined as PagesIcon,
|
||||
Public as PublicIcon,
|
||||
Navigation as NavigationIcon,
|
||||
Foundation as FooterIcon
|
||||
} from '@mui/icons-material';
|
||||
import { DataGrid, GridColDef, GridActionsCellItem } from '@mui/x-data-grid';
|
||||
import { PageEditor } from '@/components/admin/pages/page-editor';
|
||||
|
||||
interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
showInNavigation: boolean;
|
||||
showInFooter: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
creator: { name: string; email: string };
|
||||
updater: { name: string; email: string };
|
||||
}
|
||||
|
||||
export default function PagesManagement() {
|
||||
const [pages, setPages] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPage, setSelectedPage] = useState<Page | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pageToDelete, setPageToDelete] = useState<Page | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPages();
|
||||
}, [filterStatus, searchQuery]);
|
||||
|
||||
const fetchPages = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const params = new URLSearchParams();
|
||||
if (filterStatus !== 'all') params.append('status', filterStatus);
|
||||
if (searchQuery) params.append('search', searchQuery);
|
||||
|
||||
const response = await fetch(`/api/admin/pages?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pages');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPages(data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching pages:', error);
|
||||
setError('Failed to load pages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePage = () => {
|
||||
setSelectedPage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPage = (page: Page) => {
|
||||
setSelectedPage(page);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeletePage = async (page: Page) => {
|
||||
setPageToDelete(page);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeletePage = async () => {
|
||||
if (!pageToDelete) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`/api/admin/pages/${pageToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete page');
|
||||
}
|
||||
|
||||
setSuccess('Page deleted successfully');
|
||||
fetchPages();
|
||||
setDeleteDialogOpen(false);
|
||||
setPageToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
setError('Failed to delete page');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSaved = () => {
|
||||
setSuccess('Page saved successfully');
|
||||
setEditorOpen(false);
|
||||
fetchPages();
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PUBLISHED': return 'success';
|
||||
case 'DRAFT': return 'warning';
|
||||
case 'ARCHIVED': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
width: 300,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{params.value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
/{params.row.slug}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Status',
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Chip
|
||||
label={params.value}
|
||||
color={getStatusColor(params.value) as any}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'showInNavigation',
|
||||
headerName: 'Navigation',
|
||||
width: 100,
|
||||
renderCell: (params) => (
|
||||
params.value ? <NavigationIcon color="primary" /> : null
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'showInFooter',
|
||||
headerName: 'Footer',
|
||||
width: 80,
|
||||
renderCell: (params) => (
|
||||
params.value ? <FooterIcon color="primary" /> : null
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'creator',
|
||||
headerName: 'Created By',
|
||||
width: 150,
|
||||
renderCell: (params) => params.value?.name || params.value?.email
|
||||
},
|
||||
{
|
||||
field: 'updatedAt',
|
||||
headerName: 'Last Updated',
|
||||
width: 150,
|
||||
renderCell: (params) => new Date(params.value).toLocaleDateString()
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 120,
|
||||
getActions: (params) => [
|
||||
<GridActionsCellItem
|
||||
key="edit"
|
||||
icon={<EditIcon />}
|
||||
label="Edit"
|
||||
onClick={() => handleEditPage(params.row)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
key="view"
|
||||
icon={<ViewIcon />}
|
||||
label="View"
|
||||
onClick={() => window.open(`/pages/${params.row.slug}`, '_blank')}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
key="delete"
|
||||
icon={<DeleteIcon />}
|
||||
label="Delete"
|
||||
onClick={() => handleDeletePage(params.row)}
|
||||
/>
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs aria-label="breadcrumb" sx={{ mb: 3 }}>
|
||||
<Link
|
||||
underline="hover"
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
color="inherit"
|
||||
href="/admin"
|
||||
>
|
||||
<HomeIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Admin
|
||||
</Link>
|
||||
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PagesIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Pages
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Page Management
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Create and manage website pages for navigation and footer
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreatePage}
|
||||
sx={{ height: 'fit-content' }}
|
||||
>
|
||||
New Page
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" onClose={() => setSuccess(null)} sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Search pages"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={filterStatus}
|
||||
label="Status"
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All Status</MenuItem>
|
||||
<MenuItem value="published">Published</MenuItem>
|
||||
<MenuItem value="draft">Draft</MenuItem>
|
||||
<MenuItem value="archived">Archived</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pages Table */}
|
||||
<Card>
|
||||
<DataGrid
|
||||
rows={pages}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
autoHeight
|
||||
pageSize={25}
|
||||
disableSelectionOnClick
|
||||
sx={{
|
||||
'& .MuiDataGrid-cell': {
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Page Editor Dialog */}
|
||||
<PageEditor
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
page={selectedPage}
|
||||
onSave={handlePageSaved}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete Page</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={confirmDeletePage} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user