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:
@@ -32,7 +32,8 @@ import {
|
||||
AccountCircle,
|
||||
AdminPanelSettings,
|
||||
Launch as LaunchIcon,
|
||||
Article as PageIcon
|
||||
Article as PageIcon,
|
||||
Share
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
@@ -51,6 +52,7 @@ const menuItems = [
|
||||
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
|
||||
{ text: 'Users', icon: People, href: '/admin/users' },
|
||||
{ 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: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
||||
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
||||
|
||||
@@ -56,11 +56,8 @@ export function ImageUpload({ open, onClose, onImageSelect }: ImageUploadProps)
|
||||
const fetchMediaFiles = async () => {
|
||||
setLoadingMedia(true);
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch('/api/admin/media?type=image', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -102,12 +99,9 @@ export function ImageUpload({ open, onClose, onImageSelect }: ImageUploadProps)
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch('/api/admin/media', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
credentials: 'include',
|
||||
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,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { ImageUpload } from './image-upload';
|
||||
import { SimpleRichEditor } from './simple-rich-editor';
|
||||
|
||||
interface Page {
|
||||
id?: string;
|
||||
@@ -128,16 +128,15 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = page ? `/api/admin/pages/${page.id}` : '/api/admin/pages';
|
||||
const method = page ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
@@ -174,54 +173,16 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
||||
case 'RICH_TEXT':
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2">Content</Typography>
|
||||
<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>
|
||||
<Editor
|
||||
onInit={(evt, editor) => editorRef.current = { getEditor: () => editor }}
|
||||
<SimpleRichEditor
|
||||
value={formData.content}
|
||||
onEditorChange={(content) => setFormData(prev => ({ ...prev, content }))}
|
||||
init={{
|
||||
height: 400,
|
||||
menubar: true,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
||||
'insertdatetime', 'media', 'table', 'help', 'wordcount'
|
||||
],
|
||||
toolbar: 'undo redo | blocks | ' +
|
||||
'bold italic forecolor | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
||||
'removeformat | link image | code | help',
|
||||
content_style: 'body { font-family: Arial, Helvetica, sans-serif; font-size: 14px }',
|
||||
images_upload_handler: (blobInfo: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', blobInfo.blob(), blobInfo.filename());
|
||||
|
||||
const token = localStorage.getItem('authToken');
|
||||
fetch('/api/admin/media', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
resolve(result.data.url);
|
||||
} else {
|
||||
reject(result.error || 'Upload failed');
|
||||
}
|
||||
})
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={(content) => setFormData(prev => ({ ...prev, content }))}
|
||||
height={800}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -237,7 +198,7 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
||||
</Box>
|
||||
<TextField
|
||||
multiline
|
||||
rows={20}
|
||||
rows={60}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={formData.content}
|
||||
@@ -253,7 +214,7 @@ export function PageEditor({ open, onClose, page, onSave }: PageEditorProps) {
|
||||
<Typography variant="subtitle2" gutterBottom>Markdown Content</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={20}
|
||||
rows={60}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
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,
|
||||
Instagram,
|
||||
YouTube,
|
||||
LinkedIn,
|
||||
GitHub,
|
||||
MusicNote as TikTok,
|
||||
Share as DefaultIcon
|
||||
} from '@mui/icons-material'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
@@ -27,8 +31,18 @@ interface DynamicPage {
|
||||
footerOrder?: number
|
||||
}
|
||||
|
||||
interface SocialMediaLink {
|
||||
id: string
|
||||
platform: string
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
const [dynamicPages, setDynamicPages] = useState<DynamicPage[]>([])
|
||||
const [socialLinks, setSocialLinks] = useState<SocialMediaLink[]>([])
|
||||
const router = useRouter()
|
||||
const t = useTranslations('home')
|
||||
const tSeo = useTranslations('seo')
|
||||
@@ -36,6 +50,7 @@ export function Footer() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchDynamicPages()
|
||||
fetchSocialLinks()
|
||||
}, [])
|
||||
|
||||
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 = () => {
|
||||
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 (
|
||||
<Paper component="footer" sx={{ bgcolor: 'grey.900', color: 'white', py: 6 }}>
|
||||
<Container maxWidth="lg">
|
||||
@@ -74,12 +115,7 @@ export function Footer() {
|
||||
{t('footer.quickLinks.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.about')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.blog')}
|
||||
</Button>
|
||||
{/* Static important links */}
|
||||
<Button
|
||||
color="inherit"
|
||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||
@@ -87,22 +123,21 @@ export function Footer() {
|
||||
>
|
||||
{t('footer.quickLinks.contact')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.support')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.quickLinks.api')}
|
||||
</Button>
|
||||
{dynamicPages.map((page) => (
|
||||
<Button
|
||||
key={page.id}
|
||||
color="inherit"
|
||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
|
||||
>
|
||||
{page.title}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* Dynamic pages - filtered for non-legal pages */}
|
||||
{dynamicPages
|
||||
.filter(page => !['terms', 'privacy', 'cookies', 'gdpr'].includes(page.slug))
|
||||
.sort((a, b) => (a.footerOrder || 999) - (b.footerOrder || 999))
|
||||
.map((page) => (
|
||||
<Button
|
||||
key={page.id}
|
||||
color="inherit"
|
||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
|
||||
>
|
||||
{page.title}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -112,18 +147,20 @@ export function Footer() {
|
||||
{t('footer.legal.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.terms')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.privacy')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.cookies')}
|
||||
</Button>
|
||||
<Button color="inherit" sx={{ justifyContent: 'flex-start', p: 0 }}>
|
||||
{t('footer.legal.gdpr')}
|
||||
</Button>
|
||||
{/* Dynamic legal pages */}
|
||||
{dynamicPages
|
||||
.filter(page => ['terms', 'privacy', 'cookies', 'gdpr'].includes(page.slug))
|
||||
.sort((a, b) => (a.footerOrder || 999) - (b.footerOrder || 999))
|
||||
.map((page) => (
|
||||
<Button
|
||||
key={page.id}
|
||||
color="inherit"
|
||||
sx={{ justifyContent: 'flex-start', p: 0 }}
|
||||
onClick={() => router.push(`/${locale}/pages/${page.slug}`)}
|
||||
>
|
||||
{page.title}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -132,19 +169,25 @@ export function Footer() {
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{t('footer.social.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Facebook />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Twitter />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<Instagram />
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="small">
|
||||
<YouTube />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{socialLinks.map((link) => (
|
||||
<IconButton
|
||||
key={link.id}
|
||||
color="inherit"
|
||||
size="small"
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={link.name}
|
||||
>
|
||||
{renderSocialIcon(link.icon)}
|
||||
</IconButton>
|
||||
))}
|
||||
{socialLinks.length === 0 && (
|
||||
<Typography variant="caption" color="grey.500">
|
||||
No social media links configured
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user