## Social Media Management System - Add SocialMediaLink database model with platform, URL, icon, and ordering - Create complete CRUD API endpoints for admin social media management - Implement admin social media management page with Material-UI DataGrid - Add "Social Media" menu item to admin navigation - Update footer to dynamically load and display enabled social media links - Support multiple platforms: Facebook, Twitter, Instagram, YouTube, LinkedIn, GitHub, TikTok - Include proper icon mapping and fallback handling ## Admin Pages Improvements - Replace broken TinyMCE editor with working WYSIWYG rich text editor - Create SimpleRichEditor component with toolbar for formatting - Fix admin authentication to use cookies instead of localStorage tokens - Update all admin API calls to use credentials: 'include' - Increase content editor height to 800px for better editing experience - Add Lexical editor component as alternative (not currently used) ## Footer Pages System - Create 8 comprehensive footer pages: About, Blog, Support, API Docs, Terms, Privacy, Cookies, GDPR - Implement dynamic footer link management with smart categorization - Separate Quick Links and Legal sections with automatic filtering - Remove duplicate hardcoded links and use database-driven system - All footer pages are fully written with professional content ## Database & Dependencies - Add uuid package for ID generation - Update Prisma schema with new SocialMediaLink model and relations - Seed default social media links for Facebook, Twitter, Instagram, YouTube - Add Lexical rich text editor packages (@lexical/react, etc.) ## Technical Improvements - Fix async params compatibility for Next.js 15 - Update MUI DataGrid deprecated props - Improve admin layout navigation structure - Add proper TypeScript interfaces for all new components - Implement proper error handling and user feedback 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
432 lines
13 KiB
TypeScript
432 lines
13 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 { ImageUpload } from './image-upload';
|
|
import { SimpleRichEditor } from './simple-rich-editor';
|
|
|
|
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 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'
|
|
},
|
|
credentials: 'include',
|
|
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: 2 }}>
|
|
<Typography variant="subtitle2">Rich Text Content</Typography>
|
|
<Button size="small" onClick={() => setImageUploadOpen(true)}>
|
|
Insert Image
|
|
</Button>
|
|
</Box>
|
|
<SimpleRichEditor
|
|
value={formData.content}
|
|
onChange={(content) => setFormData(prev => ({ ...prev, content }))}
|
|
height={800}
|
|
/>
|
|
</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={60}
|
|
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={60}
|
|
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>
|
|
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
|
<Box sx={{ flex: { xs: '1', sm: '2' } }}>
|
|
<TextField
|
|
label="Page Title"
|
|
fullWidth
|
|
required
|
|
value={formData.title}
|
|
onChange={(e) => handleTitleChange(e.target.value)}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ flex: '1' }}>
|
|
<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>
|
|
</Box>
|
|
</Box>
|
|
|
|
<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>
|
|
<Box sx={{ display: 'flex', gap: 3, flexDirection: { xs: 'column', sm: 'row' } }}>
|
|
<Box sx={{ flex: '1' }}>
|
|
<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>
|
|
</Box>
|
|
|
|
<Box sx={{ flex: '1' }}>
|
|
<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>
|
|
</Box>
|
|
</Box>
|
|
</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}
|
|
/>
|
|
</>
|
|
);
|
|
} |