Add comprehensive social media management system and improve admin pages
## 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>
This commit is contained in:
@@ -216,10 +216,10 @@ export default function BibleReaderNew() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll)
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Fetch versions based on current locale
|
// Fetch all bible versions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVersionsLoading(true)
|
setVersionsLoading(true)
|
||||||
fetch(`/api/bible/versions?language=${locale}`)
|
fetch(`/api/bible/versions?all=true`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.versions) {
|
if (data.success && data.versions) {
|
||||||
@@ -692,10 +692,17 @@ export default function BibleReaderNew() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={versionsLoading}
|
disabled={versionsLoading}
|
||||||
|
MenuProps={{
|
||||||
|
PaperProps: {
|
||||||
|
style: {
|
||||||
|
maxHeight: 400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{versions.map((version) => (
|
{versions.map((version) => (
|
||||||
<MenuItem key={version.id} value={version.id}>
|
<MenuItem key={version.id} value={version.id}>
|
||||||
{version.abbreviation} - {version.name}
|
{version.abbreviation} - {version.name} ({version.language.toUpperCase()})
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -69,15 +69,12 @@ export default function PagesManagement() {
|
|||||||
|
|
||||||
const fetchPages = async () => {
|
const fetchPages = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filterStatus !== 'all') params.append('status', filterStatus);
|
if (filterStatus !== 'all') params.append('status', filterStatus);
|
||||||
if (searchQuery) params.append('search', searchQuery);
|
if (searchQuery) params.append('search', searchQuery);
|
||||||
|
|
||||||
const response = await fetch(`/api/admin/pages?${params.toString()}`, {
|
const response = await fetch(`/api/admin/pages?${params.toString()}`, {
|
||||||
headers: {
|
credentials: 'include'
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -113,12 +110,9 @@ export default function PagesManagement() {
|
|||||||
if (!pageToDelete) return;
|
if (!pageToDelete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
const response = await fetch(`/api/admin/pages/${pageToDelete.id}`, {
|
const response = await fetch(`/api/admin/pages/${pageToDelete.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
credentials: 'include'
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
457
app/admin/social-media/page.tsx
Normal file
457
app/admin/social-media/page.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Alert,
|
||||||
|
Breadcrumbs,
|
||||||
|
Link
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Home as HomeIcon,
|
||||||
|
Share as ShareIcon,
|
||||||
|
Facebook,
|
||||||
|
Twitter,
|
||||||
|
Instagram,
|
||||||
|
YouTube,
|
||||||
|
LinkedIn,
|
||||||
|
GitHub,
|
||||||
|
MusicNote as TikTok
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { DataGrid, GridColDef, GridActionsCellItem } from '@mui/x-data-grid';
|
||||||
|
|
||||||
|
interface SocialMediaLink {
|
||||||
|
id: string;
|
||||||
|
platform: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
order: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
creator: { name: string; email: string };
|
||||||
|
updater: { name: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformIcons = {
|
||||||
|
'Facebook': Facebook,
|
||||||
|
'Twitter': Twitter,
|
||||||
|
'Instagram': Instagram,
|
||||||
|
'YouTube': YouTube,
|
||||||
|
'LinkedIn': LinkedIn,
|
||||||
|
'GitHub': GitHub,
|
||||||
|
'TikTok': TikTok
|
||||||
|
};
|
||||||
|
|
||||||
|
const platformOptions = [
|
||||||
|
{ value: 'facebook', label: 'Facebook', icon: 'Facebook' },
|
||||||
|
{ value: 'twitter', label: 'Twitter', icon: 'Twitter' },
|
||||||
|
{ value: 'instagram', label: 'Instagram', icon: 'Instagram' },
|
||||||
|
{ value: 'youtube', label: 'YouTube', icon: 'YouTube' },
|
||||||
|
{ value: 'linkedin', label: 'LinkedIn', icon: 'LinkedIn' },
|
||||||
|
{ value: 'github', label: 'GitHub', icon: 'GitHub' },
|
||||||
|
{ value: 'tiktok', label: 'TikTok', icon: 'TikTok' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SocialMediaManagement() {
|
||||||
|
const [socialLinks, setSocialLinks] = useState<SocialMediaLink[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedLink, setSelectedLink] = useState<SocialMediaLink | null>(null);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [linkToDelete, setLinkToDelete] = useState<SocialMediaLink | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
platform: '',
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
icon: '',
|
||||||
|
isEnabled: true,
|
||||||
|
order: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSocialLinks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSocialLinks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/social-media', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch social media links');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setSocialLinks(data.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching social media links:', error);
|
||||||
|
setError('Failed to load social media links');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLink = () => {
|
||||||
|
setSelectedLink(null);
|
||||||
|
setFormData({
|
||||||
|
platform: '',
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
icon: '',
|
||||||
|
isEnabled: true,
|
||||||
|
order: socialLinks.length
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditLink = (link: SocialMediaLink) => {
|
||||||
|
setSelectedLink(link);
|
||||||
|
setFormData({
|
||||||
|
platform: link.platform,
|
||||||
|
name: link.name,
|
||||||
|
url: link.url,
|
||||||
|
icon: link.icon,
|
||||||
|
isEnabled: link.isEnabled,
|
||||||
|
order: link.order
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLink = (link: SocialMediaLink) => {
|
||||||
|
setLinkToDelete(link);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLink = async () => {
|
||||||
|
try {
|
||||||
|
const url = selectedLink
|
||||||
|
? `/api/admin/social-media/${selectedLink.id}`
|
||||||
|
: '/api/admin/social-media';
|
||||||
|
const method = selectedLink ? '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 social media link');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess('Social media link saved successfully');
|
||||||
|
setEditorOpen(false);
|
||||||
|
fetchSocialLinks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving social media link:', error);
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to save social media link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteLink = async () => {
|
||||||
|
if (!linkToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/social-media/${linkToDelete.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete social media link');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess('Social media link deleted successfully');
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setLinkToDelete(null);
|
||||||
|
fetchSocialLinks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting social media link:', error);
|
||||||
|
setError('Failed to delete social media link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlatformChange = (platform: string) => {
|
||||||
|
const platformOption = platformOptions.find(p => p.value === platform);
|
||||||
|
if (platformOption) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
platform,
|
||||||
|
name: platformOption.label,
|
||||||
|
icon: platformOption.icon
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIcon = (iconName: string) => {
|
||||||
|
const IconComponent = platformIcons[iconName as keyof typeof platformIcons];
|
||||||
|
return IconComponent ? <IconComponent /> : <ShareIcon />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: 'icon',
|
||||||
|
headerName: 'Icon',
|
||||||
|
width: 80,
|
||||||
|
renderCell: (params) => renderIcon(params.value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
headerName: 'Platform',
|
||||||
|
width: 120,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight="medium">
|
||||||
|
{params.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{params.row.platform}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'url',
|
||||||
|
headerName: 'URL',
|
||||||
|
width: 300,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Link href={params.value} target="_blank" rel="noopener">
|
||||||
|
{params.value}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'isEnabled',
|
||||||
|
headerName: 'Status',
|
||||||
|
width: 100,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Chip
|
||||||
|
label={params.value ? 'Enabled' : 'Disabled'}
|
||||||
|
color={params.value ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'order',
|
||||||
|
headerName: 'Order',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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={() => handleEditLink(params.row)}
|
||||||
|
/>,
|
||||||
|
<GridActionsCellItem
|
||||||
|
key="delete"
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={() => handleDeleteLink(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' }}>
|
||||||
|
<ShareIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||||
|
Social Media
|
||||||
|
</Typography>
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Social Media Management
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Manage social media links displayed in the footer
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={handleCreateLink}
|
||||||
|
sx={{ height: 'fit-content' }}
|
||||||
|
>
|
||||||
|
Add Social Link
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Social Links Table */}
|
||||||
|
<Card>
|
||||||
|
<DataGrid
|
||||||
|
rows={socialLinks}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
autoHeight
|
||||||
|
initialState={{
|
||||||
|
pagination: {
|
||||||
|
paginationModel: { pageSize: 25, page: 0 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[25, 50, 100]}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
sx={{
|
||||||
|
'& .MuiDataGrid-cell': {
|
||||||
|
borderBottom: '1px solid #f0f0f0'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Editor Dialog */}
|
||||||
|
<Dialog open={editorOpen} onClose={() => setEditorOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedLink ? 'Edit Social Media Link' : 'Add Social Media Link'}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Platform</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.platform}
|
||||||
|
label="Platform"
|
||||||
|
onChange={(e) => handlePlatformChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{platformOptions.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{renderIcon(option.icon)}
|
||||||
|
{option.label}
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Display Name"
|
||||||
|
fullWidth
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="URL"
|
||||||
|
fullWidth
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, url: e.target.value }))}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Display Order"
|
||||||
|
type="number"
|
||||||
|
fullWidth
|
||||||
|
value={formData.order}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, order: parseInt(e.target.value) || 0 }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={formData.isEnabled}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, isEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enabled"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditorOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSaveLink} variant="contained">
|
||||||
|
{selectedLink ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||||
|
<DialogTitle>Delete Social Media Link</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you want to delete the {linkToDelete?.name} link? This action cannot be undone.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={confirmDeleteLink} color="error" variant="contained">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
app/api/admin/social-media/[id]/route.ts
Normal file
143
app/api/admin/social-media/[id]/route.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { verifyAdminAuth } from '@/lib/admin-auth';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const adminUser = await verifyAdminAuth(request);
|
||||||
|
if (!adminUser) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const socialMediaLink = await prisma.socialMediaLink.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
creator: { select: { name: true, email: true } },
|
||||||
|
updater: { select: { name: true, email: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!socialMediaLink) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Social media link not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: socialMediaLink
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching social media link:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch social media link' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const adminUser = await verifyAdminAuth(request);
|
||||||
|
if (!adminUser) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
platform,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
isEnabled,
|
||||||
|
order
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!platform || !name || !url || !icon) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Platform, name, URL, and icon are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if another link uses the same platform
|
||||||
|
const existingLink = await prisma.socialMediaLink.findFirst({
|
||||||
|
where: {
|
||||||
|
platform,
|
||||||
|
id: { not: id }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingLink) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Another social media link for this platform already exists' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialMediaLink = await prisma.socialMediaLink.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
platform,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
isEnabled,
|
||||||
|
order,
|
||||||
|
updatedBy: adminUser.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
creator: { select: { name: true, email: true } },
|
||||||
|
updater: { select: { name: true, email: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: socialMediaLink
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating social media link:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to update social media link' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const adminUser = await verifyAdminAuth(request);
|
||||||
|
if (!adminUser) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.socialMediaLink.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Social media link deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting social media link:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to delete social media link' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/api/admin/social-media/route.ts
Normal file
97
app/api/admin/social-media/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { verifyAdminAuth } from '@/lib/admin-auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const adminUser = await verifyAdminAuth(request);
|
||||||
|
if (!adminUser) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialMediaLinks = await prisma.socialMediaLink.findMany({
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: {
|
||||||
|
creator: { select: { name: true, email: true } },
|
||||||
|
updater: { select: { name: true, email: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: socialMediaLinks
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching social media links:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch social media links' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const adminUser = await verifyAdminAuth(request);
|
||||||
|
if (!adminUser) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
platform,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
isEnabled = true,
|
||||||
|
order = 0
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!platform || !name || !url || !icon) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Platform, name, URL, and icon are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if platform already exists
|
||||||
|
const existingLink = await prisma.socialMediaLink.findUnique({
|
||||||
|
where: { platform }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingLink) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'A social media link for this platform already exists' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialMediaLink = await prisma.socialMediaLink.create({
|
||||||
|
data: {
|
||||||
|
platform,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
isEnabled,
|
||||||
|
order,
|
||||||
|
createdBy: adminUser.id,
|
||||||
|
updatedBy: adminUser.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
creator: { select: { name: true, email: true } },
|
||||||
|
updater: { select: { name: true, email: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: socialMediaLink
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating social media link:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to create social media link' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,10 @@ export async function GET(request: Request) {
|
|||||||
let bibleVersion
|
let bibleVersion
|
||||||
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
|
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
|
||||||
if (versionId) {
|
if (versionId) {
|
||||||
// Use specific version if provided
|
// Use specific version if provided (no language filter needed)
|
||||||
bibleVersion = await prisma.bibleVersion.findFirst({
|
bibleVersion = await prisma.bibleVersion.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: versionId,
|
id: versionId
|
||||||
language: { in: langCandidates }
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const locale = (searchParams.get('locale') || 'ro').toLowerCase()
|
const locale = (searchParams.get('locale') || 'ro').toLowerCase()
|
||||||
|
const showAll = searchParams.get('all') === 'true'
|
||||||
|
|
||||||
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
|
let whereClause = {}
|
||||||
|
|
||||||
|
if (!showAll) {
|
||||||
|
const langCandidates = Array.from(new Set([locale, locale.toLowerCase(), locale.toUpperCase()]))
|
||||||
|
whereClause = { language: { in: langCandidates } }
|
||||||
|
}
|
||||||
|
|
||||||
const versions = await prisma.bibleVersion.findMany({
|
const versions = await prisma.bibleVersion.findMany({
|
||||||
where: { language: { in: langCandidates } },
|
where: whereClause,
|
||||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }]
|
orderBy: [{ isDefault: 'desc' }, { language: 'asc' }, { name: 'asc' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
30
app/api/social-media/route.ts
Normal file
30
app/api/social-media/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const socialMediaLinks = await prisma.socialMediaLink.findMany({
|
||||||
|
where: { isEnabled: true },
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
platform: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
icon: true,
|
||||||
|
order: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: socialMediaLinks
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching social media links:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch social media links' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@ import {
|
|||||||
AccountCircle,
|
AccountCircle,
|
||||||
AdminPanelSettings,
|
AdminPanelSettings,
|
||||||
Launch as LaunchIcon,
|
Launch as LaunchIcon,
|
||||||
Article as PageIcon
|
Article as PageIcon,
|
||||||
|
Share
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
@@ -51,6 +52,7 @@ const menuItems = [
|
|||||||
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
|
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
|
||||||
{ text: 'Users', icon: People, href: '/admin/users' },
|
{ text: 'Users', icon: People, href: '/admin/users' },
|
||||||
{ text: 'Pages', icon: PageIcon, href: '/admin/pages' },
|
{ text: 'Pages', icon: PageIcon, href: '/admin/pages' },
|
||||||
|
{ text: 'Social Media', icon: Share, href: '/admin/social-media' },
|
||||||
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
||||||
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
||||||
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
||||||
|
|||||||
@@ -56,11 +56,8 @@ export function ImageUpload({ open, onClose, onImageSelect }: ImageUploadProps)
|
|||||||
const fetchMediaFiles = async () => {
|
const fetchMediaFiles = async () => {
|
||||||
setLoadingMedia(true);
|
setLoadingMedia(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
const response = await fetch('/api/admin/media?type=image', {
|
const response = await fetch('/api/admin/media?type=image', {
|
||||||
headers: {
|
credentials: 'include'
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -102,12 +99,9 @@ export function ImageUpload({ open, onClose, onImageSelect }: ImageUploadProps)
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
const response = await fetch('/api/admin/media', {
|
const response = await fetch('/api/admin/media', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
349
components/admin/pages/lexical-editor.tsx
Normal file
349
components/admin/pages/lexical-editor.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { $getRoot, $getSelection, $createParagraphNode, $createTextNode, UNDO_COMMAND, REDO_COMMAND, FORMAT_TEXT_COMMAND } from 'lexical';
|
||||||
|
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
|
||||||
|
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||||
|
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
|
||||||
|
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||||
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||||
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||||
|
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||||
|
import { $createHeadingNode } from '@lexical/rich-text';
|
||||||
|
import { $createListNode, $createListItemNode } from '@lexical/list';
|
||||||
|
import { $createLinkNode } from '@lexical/link';
|
||||||
|
import { Box, Toolbar, IconButton, Divider, Select, MenuItem, FormControl } from '@mui/material';
|
||||||
|
import {
|
||||||
|
FormatBold,
|
||||||
|
FormatItalic,
|
||||||
|
FormatUnderlined,
|
||||||
|
FormatListBulleted,
|
||||||
|
FormatListNumbered,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
Undo,
|
||||||
|
Redo
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
ltr: 'ltr',
|
||||||
|
rtl: 'rtl',
|
||||||
|
paragraph: 'editor-paragraph',
|
||||||
|
quote: 'editor-quote',
|
||||||
|
heading: {
|
||||||
|
h1: 'editor-heading-h1',
|
||||||
|
h2: 'editor-heading-h2',
|
||||||
|
h3: 'editor-heading-h3',
|
||||||
|
h4: 'editor-heading-h4',
|
||||||
|
h5: 'editor-heading-h5',
|
||||||
|
h6: 'editor-heading-h6',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
nested: {
|
||||||
|
listitem: 'editor-nested-listitem',
|
||||||
|
},
|
||||||
|
ol: 'editor-list-ol',
|
||||||
|
ul: 'editor-list-ul',
|
||||||
|
listitem: 'editor-listitem',
|
||||||
|
},
|
||||||
|
image: 'editor-image',
|
||||||
|
link: 'editor-link',
|
||||||
|
text: {
|
||||||
|
bold: 'editor-text-bold',
|
||||||
|
italic: 'editor-text-italic',
|
||||||
|
overflowed: 'editor-text-overflowed',
|
||||||
|
hashtag: 'editor-text-hashtag',
|
||||||
|
underline: 'editor-text-underline',
|
||||||
|
strikethrough: 'editor-text-strikethrough',
|
||||||
|
underlineStrikethrough: 'editor-text-underlineStrikethrough',
|
||||||
|
code: 'editor-text-code',
|
||||||
|
},
|
||||||
|
code: 'editor-code',
|
||||||
|
codeHighlight: {
|
||||||
|
atrule: 'editor-tokenAttr',
|
||||||
|
attr: 'editor-tokenAttr',
|
||||||
|
boolean: 'editor-tokenProperty',
|
||||||
|
builtin: 'editor-tokenSelector',
|
||||||
|
cdata: 'editor-tokenComment',
|
||||||
|
char: 'editor-tokenSelector',
|
||||||
|
class: 'editor-tokenFunction',
|
||||||
|
'class-name': 'editor-tokenFunction',
|
||||||
|
comment: 'editor-tokenComment',
|
||||||
|
constant: 'editor-tokenProperty',
|
||||||
|
deleted: 'editor-tokenProperty',
|
||||||
|
doctype: 'editor-tokenComment',
|
||||||
|
entity: 'editor-tokenOperator',
|
||||||
|
function: 'editor-tokenFunction',
|
||||||
|
important: 'editor-tokenVariable',
|
||||||
|
inserted: 'editor-tokenSelector',
|
||||||
|
keyword: 'editor-tokenAttr',
|
||||||
|
namespace: 'editor-tokenVariable',
|
||||||
|
number: 'editor-tokenProperty',
|
||||||
|
operator: 'editor-tokenOperator',
|
||||||
|
prolog: 'editor-tokenComment',
|
||||||
|
property: 'editor-tokenProperty',
|
||||||
|
punctuation: 'editor-tokenPunctuation',
|
||||||
|
regex: 'editor-tokenVariable',
|
||||||
|
selector: 'editor-tokenSelector',
|
||||||
|
string: 'editor-tokenSelector',
|
||||||
|
symbol: 'editor-tokenProperty',
|
||||||
|
tag: 'editor-tokenProperty',
|
||||||
|
url: 'editor-tokenOperator',
|
||||||
|
variable: 'editor-tokenVariable',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LexicalEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarPlugin() {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [headingType, setHeadingType] = useState('paragraph');
|
||||||
|
|
||||||
|
const formatText = (format: 'bold' | 'italic' | 'underline') => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertHeading = (headingType: string) => {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if (selection) {
|
||||||
|
if (headingType === 'paragraph') {
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
selection.insertNodes([paragraph]);
|
||||||
|
} else {
|
||||||
|
const heading = $createHeadingNode(headingType as any);
|
||||||
|
selection.insertNodes([heading]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderBottom: 'none',
|
||||||
|
minHeight: 48,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 120, mr: 1 }}>
|
||||||
|
<Select
|
||||||
|
value={headingType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setHeadingType(value);
|
||||||
|
insertHeading(value);
|
||||||
|
}}
|
||||||
|
displayEmpty
|
||||||
|
>
|
||||||
|
<MenuItem value="paragraph">Paragraph</MenuItem>
|
||||||
|
<MenuItem value="h1">Heading 1</MenuItem>
|
||||||
|
<MenuItem value="h2">Heading 2</MenuItem>
|
||||||
|
<MenuItem value="h3">Heading 3</MenuItem>
|
||||||
|
<MenuItem value="h4">Heading 4</MenuItem>
|
||||||
|
<MenuItem value="h5">Heading 5</MenuItem>
|
||||||
|
<MenuItem value="h6">Heading 6</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton onClick={() => formatText('bold')} size="small">
|
||||||
|
<FormatBold />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => formatText('italic')} size="small">
|
||||||
|
<FormatItalic />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => formatText('underline')} size="small">
|
||||||
|
<FormatUnderlined />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton size="small">
|
||||||
|
<FormatListBulleted />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small">
|
||||||
|
<FormatListNumbered />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton size="small">
|
||||||
|
<Link />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small">
|
||||||
|
<Image />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Undo />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Redo />
|
||||||
|
</IconButton>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnChangeHandler({ onChange }: { onChange: (value: string) => void }) {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
editor.update(() => {
|
||||||
|
const htmlString = $generateHtmlFromNodes(editor, null);
|
||||||
|
onChange(htmlString);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <OnChangePlugin onChange={handleChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InitialContentPlugin({ content }: { content: string }) {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (content) {
|
||||||
|
editor.update(() => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const dom = parser.parseFromString(content, 'text/html');
|
||||||
|
const nodes = $generateNodesFromDOM(editor, dom);
|
||||||
|
const root = $getRoot();
|
||||||
|
root.clear();
|
||||||
|
root.append(...nodes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editor, content]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LexicalEditor({ value, onChange, height = 400 }: LexicalEditorProps) {
|
||||||
|
const initialConfig = {
|
||||||
|
namespace: 'MyEditor',
|
||||||
|
theme,
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error('Lexical error:', error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<LexicalComposer initialConfig={initialConfig}>
|
||||||
|
<Box sx={{ border: '1px solid #ddd', borderRadius: 1 }}>
|
||||||
|
<ToolbarPlugin />
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<RichTextPlugin
|
||||||
|
contentEditable={
|
||||||
|
<ContentEditable
|
||||||
|
style={{
|
||||||
|
minHeight: `${height}px`,
|
||||||
|
padding: '16px',
|
||||||
|
outline: 'none',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
fontFamily: 'inherit'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '16px',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: '14px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start writing your content...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
|
/>
|
||||||
|
<HistoryPlugin />
|
||||||
|
<OnChangeHandler onChange={onChange} />
|
||||||
|
<InitialContentPlugin content={value} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</LexicalComposer>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.editor-paragraph {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
.editor-heading-h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
}
|
||||||
|
.editor-heading-h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 14px 0 6px 0;
|
||||||
|
}
|
||||||
|
.editor-heading-h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 12px 0 4px 0;
|
||||||
|
}
|
||||||
|
.editor-heading-h4 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0 4px 0;
|
||||||
|
}
|
||||||
|
.editor-heading-h5 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 8px 0 2px 0;
|
||||||
|
}
|
||||||
|
.editor-heading-h6 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 6px 0 2px 0;
|
||||||
|
}
|
||||||
|
.editor-text-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.editor-text-italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.editor-text-underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.editor-list-ul,
|
||||||
|
.editor-list-ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
.editor-listitem {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.editor-link {
|
||||||
|
color: #1976d2;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.editor-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Paper
|
Paper
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Editor } from '@tinymce/tinymce-react';
|
|
||||||
import { ImageUpload } from './image-upload';
|
import { ImageUpload } from './image-upload';
|
||||||
|
import { SimpleRichEditor } from './simple-rich-editor';
|
||||||
|
|
||||||
interface Page {
|
interface Page {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -128,16 +128,15 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
const url = page ? `/api/admin/pages/${page.id}` : '/api/admin/pages';
|
const url = page ? `/api/admin/pages/${page.id}` : '/api/admin/pages';
|
||||||
const method = page ? 'PUT' : 'POST';
|
const method = page ? 'PUT' : 'POST';
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(formData)
|
body: JSON.stringify(formData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,54 +173,16 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
|||||||
case 'RICH_TEXT':
|
case 'RICH_TEXT':
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
<Typography variant="subtitle2">Content</Typography>
|
<Typography variant="subtitle2">Rich Text Content</Typography>
|
||||||
<Button size="small" onClick={() => setImageUploadOpen(true)}>
|
<Button size="small" onClick={() => setImageUploadOpen(true)}>
|
||||||
Insert Image
|
Insert Image
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Editor
|
<SimpleRichEditor
|
||||||
onInit={(evt, editor) => editorRef.current = { getEditor: () => editor }}
|
|
||||||
value={formData.content}
|
value={formData.content}
|
||||||
onEditorChange={(content) => setFormData(prev => ({ ...prev, content }))}
|
onChange={(content) => setFormData(prev => ({ ...prev, content }))}
|
||||||
init={{
|
height={800}
|
||||||
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>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -237,7 +198,7 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
<TextField
|
<TextField
|
||||||
multiline
|
multiline
|
||||||
rows={20}
|
rows={60}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={formData.content}
|
value={formData.content}
|
||||||
@@ -253,7 +214,7 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
|||||||
<Typography variant="subtitle2" gutterBottom>Markdown Content</Typography>
|
<Typography variant="subtitle2" gutterBottom>Markdown Content</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
multiline
|
multiline
|
||||||
rows={20}
|
rows={60}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={formData.content}
|
value={formData.content}
|
||||||
|
|||||||
202
components/admin/pages/simple-rich-editor.tsx
Normal file
202
components/admin/pages/simple-rich-editor.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState } from 'react';
|
||||||
|
import { Box, Toolbar, IconButton, Divider, Select, MenuItem, FormControl } from '@mui/material';
|
||||||
|
import {
|
||||||
|
FormatBold,
|
||||||
|
FormatItalic,
|
||||||
|
FormatUnderlined,
|
||||||
|
FormatListBulleted,
|
||||||
|
FormatListNumbered,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
Undo,
|
||||||
|
Redo
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface SimpleRichEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleRichEditor({ value, onChange, height = 400 }: SimpleRichEditorProps) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
// Update editor content when value prop changes (but not when we're updating it ourselves)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUpdating && editorRef.current && editorRef.current.innerHTML !== value) {
|
||||||
|
editorRef.current.innerHTML = value;
|
||||||
|
}
|
||||||
|
}, [value, isUpdating]);
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
setIsUpdating(true);
|
||||||
|
onChange(editorRef.current.innerHTML);
|
||||||
|
setTimeout(() => setIsUpdating(false), 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const execCommand = (command: string, value?: string) => {
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
editorRef.current?.focus();
|
||||||
|
handleInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertHeading = (tag: string) => {
|
||||||
|
if (tag === 'paragraph') {
|
||||||
|
execCommand('formatBlock', 'div');
|
||||||
|
} else {
|
||||||
|
execCommand('formatBlock', tag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ border: '1px solid #ddd', borderRadius: 1 }}>
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '1px solid #ddd',
|
||||||
|
minHeight: 48,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 120, mr: 1 }}>
|
||||||
|
<Select
|
||||||
|
defaultValue="paragraph"
|
||||||
|
displayEmpty
|
||||||
|
onChange={(e) => insertHeading(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="paragraph">Paragraph</MenuItem>
|
||||||
|
<MenuItem value="h1">Heading 1</MenuItem>
|
||||||
|
<MenuItem value="h2">Heading 2</MenuItem>
|
||||||
|
<MenuItem value="h3">Heading 3</MenuItem>
|
||||||
|
<MenuItem value="h4">Heading 4</MenuItem>
|
||||||
|
<MenuItem value="h5">Heading 5</MenuItem>
|
||||||
|
<MenuItem value="h6">Heading 6</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton onClick={() => execCommand('bold')} size="small">
|
||||||
|
<FormatBold />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => execCommand('italic')} size="small">
|
||||||
|
<FormatItalic />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => execCommand('underline')} size="small">
|
||||||
|
<FormatUnderlined />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton onClick={() => execCommand('insertUnorderedList')} size="small">
|
||||||
|
<FormatListBulleted />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => execCommand('insertOrderedList')} size="small">
|
||||||
|
<FormatListNumbered />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
const url = prompt('Enter link URL:');
|
||||||
|
if (url) execCommand('createLink', url);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Link />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||||
|
|
||||||
|
<IconButton onClick={() => execCommand('undo')} size="small">
|
||||||
|
<Undo />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => execCommand('redo')} size="small">
|
||||||
|
<Redo />
|
||||||
|
</IconButton>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
onInput={handleInput}
|
||||||
|
style={{
|
||||||
|
minHeight: `${height}px`,
|
||||||
|
padding: '16px',
|
||||||
|
outline: 'none',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: value }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
div[contenteditable] h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 14px 0 6px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 12px 0 4px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] h4 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0 4px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] h5 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 8px 0 2px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] h6 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 6px 0 2px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] ul,
|
||||||
|
div[contenteditable] ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
div[contenteditable] li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
div[contenteditable] a {
|
||||||
|
color: #1976d2;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
div[contenteditable] a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
div[contenteditable]:empty::before {
|
||||||
|
content: 'Start writing your content...';
|
||||||
|
color: #999;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
Twitter,
|
Twitter,
|
||||||
Instagram,
|
Instagram,
|
||||||
YouTube,
|
YouTube,
|
||||||
|
LinkedIn,
|
||||||
|
GitHub,
|
||||||
|
MusicNote as TikTok,
|
||||||
|
Share as DefaultIcon
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
@@ -27,8 +31,18 @@ interface DynamicPage {
|
|||||||
footerOrder?: number
|
footerOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SocialMediaLink {
|
||||||
|
id: string
|
||||||
|
platform: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
icon: string
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
|
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
|
||||||
|
const [socialLinks, setSocialLinks] = useState<SocialMediaLink[]>([])
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const t = useTranslations('home')
|
const t = useTranslations('home')
|
||||||
const tSeo = useTranslations('seo')
|
const tSeo = useTranslations('seo')
|
||||||
@@ -36,6 +50,7 @@ export function Footer() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDynamicPages()
|
fetchDynamicPages()
|
||||||
|
fetchSocialLinks()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchDynamicPages = async () => {
|
const fetchDynamicPages = async () => {
|
||||||
@@ -50,10 +65,36 @@ export function Footer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchSocialLinks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/social-media')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setSocialLinks(data.data || [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch social media links:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getCurrentYear = () => {
|
const getCurrentYear = () => {
|
||||||
return new Date().getFullYear()
|
return new Date().getFullYear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderSocialIcon = (iconName: string) => {
|
||||||
|
const iconMap = {
|
||||||
|
'Facebook': Facebook,
|
||||||
|
'Twitter': Twitter,
|
||||||
|
'Instagram': Instagram,
|
||||||
|
'YouTube': YouTube,
|
||||||
|
'LinkedIn': LinkedIn,
|
||||||
|
'GitHub': GitHub,
|
||||||
|
'TikTok': TikTok
|
||||||
|
}
|
||||||
|
const IconComponent = iconMap[iconName as keyof typeof iconMap] || DefaultIcon
|
||||||
|
return <IconComponent />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper component="footer" sx={{ bgcolor: 'grey.900', color: 'white', py: 6 }}>
|
<Paper component="footer" sx={{ bgcolor: 'grey.900', color: 'white', py: 6 }}>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
@@ -74,12 +115,7 @@ export function Footer() {
|
|||||||
{t('footer.quickLinks.title')}
|
{t('footer.quickLinks.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
{/* Static important links */}
|
||||||
{t('footer.quickLinks.about')}
|
|
||||||
</Button>
|
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
|
||||||
{t('footer.quickLinks.blog')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="inherit"
|
color="inherit"
|
||||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||||
@@ -87,22 +123,21 @@ export function Footer() {
|
|||||||
>
|
>
|
||||||
{t('footer.quickLinks.contact')}
|
{t('footer.quickLinks.contact')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
|
||||||
{t('footer.quickLinks.support')}
|
{/* Dynamic pages - filtered for non-legal pages */}
|
||||||
</Button>
|
{dynamicPages
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
.filter(page => !['terms', 'privacy', 'cookies', 'gdpr'].includes(page.slug))
|
||||||
{t('footer.quickLinks.api')}
|
.sort((a, b) => (a.footerOrder || 999) - (b.footerOrder || 999))
|
||||||
</Button>
|
.map((page) => (
|
||||||
{dynamicPages.map((page) => (
|
<Button
|
||||||
<Button
|
key={page.id}
|
||||||
key={page.id}
|
color="inherit"
|
||||||
color="inherit"
|
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
|
||||||
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
|
>
|
||||||
>
|
{page.title}
|
||||||
{page.title}
|
</Button>
|
||||||
</Button>
|
))}
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -112,18 +147,20 @@ export function Footer() {
|
|||||||
{t('footer.legal.title')}
|
{t('footer.legal.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
{/* Dynamic legal pages */}
|
||||||
{t('footer.legal.terms')}
|
{dynamicPages
|
||||||
</Button>
|
.filter(page => ['terms', 'privacy', 'cookies', 'gdpr'].includes(page.slug))
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
.sort((a, b) => (a.footerOrder || 999) - (b.footerOrder || 999))
|
||||||
{t('footer.legal.privacy')}
|
.map((page) => (
|
||||||
</Button>
|
<Button
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
key={page.id}
|
||||||
{t('footer.legal.cookies')}
|
color="inherit"
|
||||||
</Button>
|
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
|
||||||
{t('footer.legal.gdpr')}
|
>
|
||||||
</Button>
|
{page.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -132,19 +169,25 @@ export function Footer() {
|
|||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
{t('footer.social.title')}
|
{t('footer.social.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<IconButton color="inherit" size="small">
|
{socialLinks.map((link) => (
|
||||||
<Facebook />
|
<IconButton
|
||||||
</IconButton>
|
key={link.id}
|
||||||
<IconButton color="inherit" size="small">
|
color="inherit"
|
||||||
<Twitter />
|
size="small"
|
||||||
</IconButton>
|
href={link.url}
|
||||||
<IconButton color="inherit" size="small">
|
target="_blank"
|
||||||
<Instagram />
|
rel="noopener noreferrer"
|
||||||
</IconButton>
|
title={link.name}
|
||||||
<IconButton color="inherit" size="small">
|
>
|
||||||
<YouTube />
|
{renderSocialIcon(link.icon)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
))}
|
||||||
|
{socialLinks.length === 0 && (
|
||||||
|
<Typography variant="caption" color="grey.500">
|
||||||
|
No social media links configured
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
380
package-lock.json
generated
380
package-lock.json
generated
@@ -13,6 +13,13 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/roboto": "^5.2.8",
|
"@fontsource/roboto": "^5.2.8",
|
||||||
"@formatjs/intl-localematcher": "^0.6.1",
|
"@formatjs/intl-localematcher": "^0.6.1",
|
||||||
|
"@lexical/html": "^0.35.0",
|
||||||
|
"@lexical/link": "^0.35.0",
|
||||||
|
"@lexical/list": "^0.35.0",
|
||||||
|
"@lexical/plain-text": "^0.35.0",
|
||||||
|
"@lexical/react": "^0.35.0",
|
||||||
|
"@lexical/rich-text": "^0.35.0",
|
||||||
|
"@lexical/utils": "^0.35.0",
|
||||||
"@mui/icons-material": "^7.3.2",
|
"@mui/icons-material": "^7.3.2",
|
||||||
"@mui/lab": "^7.0.0-beta.17",
|
"@mui/lab": "^7.0.0-beta.17",
|
||||||
"@mui/material": "^7.3.2",
|
"@mui/material": "^7.3.2",
|
||||||
@@ -40,6 +47,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lexical": "^0.35.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
@@ -61,6 +69,7 @@
|
|||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tinymce": "^8.1.2",
|
"tinymce": "^8.1.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@@ -887,6 +896,21 @@
|
|||||||
"@floating-ui/utils": "^0.2.10"
|
"@floating-ui/utils": "^0.2.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/react": {
|
||||||
|
"version": "0.27.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz",
|
||||||
|
"integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.1.6",
|
||||||
|
"@floating-ui/utils": "^0.2.10",
|
||||||
|
"tabbable": "^6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17.0.0",
|
||||||
|
"react-dom": ">=17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/react-dom": {
|
"node_modules/@floating-ui/react-dom": {
|
||||||
"version": "2.1.6",
|
"version": "2.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
|
||||||
@@ -1451,6 +1475,261 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lexical/clipboard": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/html": "0.35.0",
|
||||||
|
"@lexical/list": "0.35.0",
|
||||||
|
"@lexical/selection": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/code": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-ox4DZwETQ9IA7+DS6PN8RJNwSAF7RMjL7YTVODIqFZ5tUFIf+5xoCHbz7Fll0Bvixlp12hVH90xnLwTLRGpkKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0",
|
||||||
|
"prismjs": "^1.30.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/devtools-core": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-C2wwtsMCR6ZTfO0TqpSM17RLJWyfHmifAfCTjFtOJu15p3M6NO/nHYK5Mt7YMQteuS89mOjB4ng8iwoLEZ6QpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/html": "0.35.0",
|
||||||
|
"@lexical/link": "0.35.0",
|
||||||
|
"@lexical/mark": "0.35.0",
|
||||||
|
"@lexical/table": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17.x",
|
||||||
|
"react-dom": ">=17.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/dragon": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-SL6mT5pcqrt6hEbJ16vWxip5+r3uvMd0bQV5UUxuk+cxIeuP86iTgRh0HFR7SM2dRTYovL6/tM/O+8QLAUGTIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/hashtag": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-LYJWzXuO2ZjKsvQwrLkNZiS2TsjwYkKjlDgtugzejquTBQ/o/nfSn/MmVx6EkYLOYizaJemmZbz3IBh+u732FA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/history": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-onjDRLLxGbCfHexSxxrQaDaieIHyV28zCDrbxR5dxTfW8F8PxjuNyuaG0z6o468AXYECmclxkP+P4aT6poHEpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/html": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-rXGFE5S5rKsg3tVnr1s4iEgOfCApNXGpIFI3T2jGEShaCZ5HLaBY9NVBXnE9Nb49e9bkDkpZ8FZd1qokCbQXbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/selection": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/link": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-+0Wx6cBwO8TfdMzpkYFacsmgFh8X1rkiYbq3xoLvk3qV8upYxaMzK1s8Q1cpKmWyI0aZrU6z7fiK4vUqB7+69w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/list": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-owsmc8iwgExBX8sFe8fKTiwJVhYULt9hD1RZ/HwfaiEtRZZkINijqReOBnW2mJfRxBzhFSWc4NG3ISB+fHYzqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/selection": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/mark": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-W0hwMTAVeexvpk9/+J6n1G/sNkpI/Meq1yeDazahFLLAwXLHtvhIAq2P/klgFknDy1hr8X7rcsQuN/bqKcKHYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/markdown": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-BlNyXZAt4gWidMw0SRWrhBETY1BpPglFBZI7yzfqukFqgXRh7HUQA28OYeI/nsx9pgNob8TiUduUwShqqvOdEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/code": "0.35.0",
|
||||||
|
"@lexical/link": "0.35.0",
|
||||||
|
"@lexical/list": "0.35.0",
|
||||||
|
"@lexical/rich-text": "0.35.0",
|
||||||
|
"@lexical/text": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/offset": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-DRE4Df6qYf2XiV6foh6KpGNmGAv2ANqt3oVXpyS6W8hTx3+cUuAA1APhCZmLNuU107um4zmHym7taCu6uXW5Yg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/overflow": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-B25YvnJQTGlZcrNv7b0PJBLWq3tl8sql497OHfYYLem7EOMPKKDGJScJAKM/91D4H/mMAsx5gnA/XgKobriuTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/plain-text": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-lwBCUNMJf7Gujp2syVWMpKRahfbTv5Wq+H3HK1Q1gKH1P2IytPRxssCHvexw9iGwprSyghkKBlbF3fGpEdIJvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/clipboard": "0.35.0",
|
||||||
|
"@lexical/selection": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/react": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-uYAZSqumH8tRymMef+A0f2hQvMwplKK9DXamcefnk3vSNDHHqRWQXpiUo6kD+rKWuQmMbVa5RW4xRQebXEW+1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.8",
|
||||||
|
"@lexical/devtools-core": "0.35.0",
|
||||||
|
"@lexical/dragon": "0.35.0",
|
||||||
|
"@lexical/hashtag": "0.35.0",
|
||||||
|
"@lexical/history": "0.35.0",
|
||||||
|
"@lexical/link": "0.35.0",
|
||||||
|
"@lexical/list": "0.35.0",
|
||||||
|
"@lexical/mark": "0.35.0",
|
||||||
|
"@lexical/markdown": "0.35.0",
|
||||||
|
"@lexical/overflow": "0.35.0",
|
||||||
|
"@lexical/plain-text": "0.35.0",
|
||||||
|
"@lexical/rich-text": "0.35.0",
|
||||||
|
"@lexical/table": "0.35.0",
|
||||||
|
"@lexical/text": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"@lexical/yjs": "0.35.0",
|
||||||
|
"lexical": "0.35.0",
|
||||||
|
"react-error-boundary": "^3.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17.x",
|
||||||
|
"react-dom": ">=17.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/rich-text": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-qEHu8g7vOEzz9GUz1VIUxZBndZRJPh9iJUFI+qTDHj+tQqnd5LCs+G9yz6jgNfiuWWpezTp0i1Vz/udNEuDPKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/clipboard": "0.35.0",
|
||||||
|
"@lexical/selection": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/selection": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-mMtDE7Q0nycXdFTTH/+ta6EBrBwxBB4Tg8QwsGntzQ1Cq//d838dpXpFjJOqHEeVHUqXpiuj+cBG8+bvz/rPRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/table": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-9jlTlkVideBKwsEnEkqkdg7A3mije1SvmfiqoYnkl1kKJCLA5iH90ywx327PU0p+bdnURAytWUeZPXaEuEl2OA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/clipboard": "0.35.0",
|
||||||
|
"@lexical/utils": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/text": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-uaMh46BkysV8hK8wQwp5g/ByZW+2hPDt8ahAErxtf8NuzQem1FHG/f5RTchmFqqUDVHO3qLNTv4AehEGmXv8MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/utils": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-2H393EYDnFznYCDFOW3MHiRzwEO5M/UBhtUjvTT+9kc+qhX4U3zc8ixQalo5UmZ5B2nh7L/inXdTFzvSRXtsRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/list": "0.35.0",
|
||||||
|
"@lexical/selection": "0.35.0",
|
||||||
|
"@lexical/table": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lexical/yjs": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-3DSP7QpmTGYU9bN/yljP0PIao4tNIQtsR4ycauWNSawxs/GQCZtSmAPcLRnCm6qpqsDDjUtKjO/1Ej8FRp0m0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/offset": "0.35.0",
|
||||||
|
"@lexical/selection": "0.35.0",
|
||||||
|
"lexical": "0.35.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": ">=13.5.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mui/core-downloads-tracker": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "7.3.2",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
|
||||||
@@ -4781,6 +5060,17 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic.js": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||||
@@ -4857,6 +5147,34 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lexical": {
|
||||||
|
"version": "0.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lexical/-/lexical-0.35.0.tgz",
|
||||||
|
"integrity": "sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lib0": {
|
||||||
|
"version": "0.2.114",
|
||||||
|
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz",
|
||||||
|
"integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"isomorphic.js": "^0.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||||
|
"0gentesthtml": "bin/gentesthtml.js",
|
||||||
|
"0serve": "bin/0serve.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||||
@@ -6621,6 +6939,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prismjs": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -6695,6 +7022,22 @@
|
|||||||
"react": "^19.1.1"
|
"react": "^19.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-error-boundary": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10",
|
||||||
|
"npm": ">=6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.1.1",
|
"version": "19.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
||||||
@@ -7326,6 +7669,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tabbable": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
||||||
@@ -7647,6 +7996,19 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -7767,6 +8129,24 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yjs": {
|
||||||
|
"version": "13.6.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
|
||||||
|
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.99"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/roboto": "^5.2.8",
|
"@fontsource/roboto": "^5.2.8",
|
||||||
"@formatjs/intl-localematcher": "^0.6.1",
|
"@formatjs/intl-localematcher": "^0.6.1",
|
||||||
|
"@lexical/html": "^0.35.0",
|
||||||
|
"@lexical/link": "^0.35.0",
|
||||||
|
"@lexical/list": "^0.35.0",
|
||||||
|
"@lexical/plain-text": "^0.35.0",
|
||||||
|
"@lexical/react": "^0.35.0",
|
||||||
|
"@lexical/rich-text": "^0.35.0",
|
||||||
|
"@lexical/utils": "^0.35.0",
|
||||||
"@mui/icons-material": "^7.3.2",
|
"@mui/icons-material": "^7.3.2",
|
||||||
"@mui/lab": "^7.0.0-beta.17",
|
"@mui/lab": "^7.0.0-beta.17",
|
||||||
"@mui/material": "^7.3.2",
|
"@mui/material": "^7.3.2",
|
||||||
@@ -53,6 +60,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lexical": "^0.35.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
@@ -74,6 +82,7 @@
|
|||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tinymce": "^8.1.2",
|
"tinymce": "^8.1.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ model User {
|
|||||||
createdPages Page[] @relation("PageCreator")
|
createdPages Page[] @relation("PageCreator")
|
||||||
updatedPages Page[] @relation("PageUpdater")
|
updatedPages Page[] @relation("PageUpdater")
|
||||||
uploadedFiles MediaFile[]
|
uploadedFiles MediaFile[]
|
||||||
|
createdSocialMedia SocialMediaLink[] @relation("SocialMediaCreator")
|
||||||
|
updatedSocialMedia SocialMediaLink[] @relation("SocialMediaUpdater")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
}
|
}
|
||||||
@@ -348,3 +350,23 @@ enum PageStatus {
|
|||||||
PUBLISHED
|
PUBLISHED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SocialMediaLink {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
platform String // facebook, twitter, instagram, youtube, linkedin, tiktok, etc.
|
||||||
|
name String // Display name (e.g., "Facebook", "Instagram")
|
||||||
|
url String // Full URL to the social media profile
|
||||||
|
icon String // Icon identifier (material-ui icon name)
|
||||||
|
isEnabled Boolean @default(true)
|
||||||
|
order Int @default(0) // Display order in footer
|
||||||
|
createdBy String
|
||||||
|
updatedBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
creator User @relation("SocialMediaCreator", fields: [createdBy], references: [id])
|
||||||
|
updater User @relation("SocialMediaUpdater", fields: [updatedBy], references: [id])
|
||||||
|
|
||||||
|
@@unique([platform])
|
||||||
|
@@index([isEnabled, order])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user