Add complete CKE Project implementation with news management system
This commit is contained in:
203
app/admin/components/ImageUpload.tsx
Normal file
203
app/admin/components/ImageUpload.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, X, Image as ImageIcon, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ImageUploadProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
onRemove: () => void;
|
||||
maxSize?: number; // in MB
|
||||
acceptedTypes?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ImageUpload({
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
maxSize = 5,
|
||||
acceptedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
className = ''
|
||||
}: ImageUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
// Проверка типа файла
|
||||
if (!acceptedTypes.includes(file.type)) {
|
||||
return `Неподдерживаемый тип файла. Разрешены: ${acceptedTypes.join(', ')}`;
|
||||
}
|
||||
|
||||
// Проверка размера файла
|
||||
if (file.size > maxSize * 1024 * 1024) {
|
||||
return `Файл слишком большой. Максимальный размер: ${maxSize} MB`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// В реальном приложении здесь будет загрузка на сервер
|
||||
// Для демонстрации используем FileReader для создания data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
onChange(result);
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError('Ошибка при загрузке файла');
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
setError('Ошибка при загрузке файла');
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onRemove();
|
||||
setError('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{value ? (
|
||||
// Предварительный просмотр изображения
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={value}
|
||||
alt="Preview"
|
||||
className="w-full h-64 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
|
||||
{/* Overlay с действиями */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFileDialog}
|
||||
className="p-2 bg-white text-gray-800 rounded-full hover:bg-gray-100 transition-colors"
|
||||
title="Заменить изображение"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
||||
title="Удалить изображение"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Область загрузки
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragActive
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
} ${isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={!isUploading ? openFileDialog : undefined}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="text-sm text-gray-600">Загрузка...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<ImageIcon className="h-12 w-12 text-gray-400" />
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium text-blue-600">Нажмите для выбора</span> или перетащите файл сюда
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
PNG, JPG, GIF, WEBP до {maxSize} MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Скрытый input для выбора файла */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptedTypes.join(',')}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{/* Сообщение об ошибке */}
|
||||
{error && (
|
||||
<div className="flex items-center space-x-2 text-red-600 text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о файле */}
|
||||
{value && !error && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<p>Изображение загружено успешно</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
218
app/admin/components/TextEditor.tsx
Normal file
218
app/admin/components/TextEditor.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Bold, Italic, List, Link, Eye, EyeOff, Type, Quote } from 'lucide-react';
|
||||
|
||||
interface TextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Введите текст...',
|
||||
rows = 12,
|
||||
className = ''
|
||||
}: TextEditorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const insertText = (before: string, after: string = '') => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = value.substring(start, end);
|
||||
|
||||
const newText = value.substring(0, start) + before + selectedText + after + value.substring(end);
|
||||
onChange(newText);
|
||||
|
||||
// Восстанавливаем фокус и позицию курсора
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + before.length, start + before.length + selectedText.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const insertAtCursor = (text: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
|
||||
const newText = value.substring(0, start) + text + value.substring(end);
|
||||
onChange(newText);
|
||||
|
||||
// Восстанавливаем фокус и позицию курсора
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const formatContent = (content: string) => {
|
||||
return content
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/^### (.*$)/gm, '<h3 class="text-lg font-semibold text-gray-900 mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.*$)/gm, '<h2 class="text-xl font-semibold text-gray-900 mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.*$)/gm, '<h1 class="text-2xl font-bold text-gray-900 mt-8 mb-4">$1</h1>')
|
||||
.replace(/^> (.*$)/gm, '<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4">$1</blockquote>')
|
||||
.replace(/^- (.*$)/gm, '<li class="mb-1">$1</li>')
|
||||
.replace(/(<li.*<\/li>)/g, '<ul class="list-disc list-inside mb-4 text-gray-700">$1</ul>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-600 hover:text-blue-800 underline" target="_blank">$1</a>');
|
||||
};
|
||||
|
||||
const toolbarButtons = [
|
||||
{
|
||||
icon: Bold,
|
||||
label: 'Жирный',
|
||||
action: () => insertText('**', '**'),
|
||||
shortcut: 'Ctrl+B'
|
||||
},
|
||||
{
|
||||
icon: Italic,
|
||||
label: 'Курсив',
|
||||
action: () => insertText('*', '*'),
|
||||
shortcut: 'Ctrl+I'
|
||||
},
|
||||
{
|
||||
icon: Type,
|
||||
label: 'Заголовок',
|
||||
action: () => insertAtCursor('## '),
|
||||
shortcut: 'Ctrl+H'
|
||||
},
|
||||
{
|
||||
icon: Quote,
|
||||
label: 'Цитата',
|
||||
action: () => insertAtCursor('> '),
|
||||
shortcut: 'Ctrl+Q'
|
||||
},
|
||||
{
|
||||
icon: List,
|
||||
label: 'Список',
|
||||
action: () => insertAtCursor('- '),
|
||||
shortcut: 'Ctrl+L'
|
||||
},
|
||||
{
|
||||
icon: Link,
|
||||
label: 'Ссылка',
|
||||
action: () => insertText('[', '](url)'),
|
||||
shortcut: 'Ctrl+K'
|
||||
}
|
||||
];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
insertText('**', '**');
|
||||
break;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
insertText('*', '*');
|
||||
break;
|
||||
case 'h':
|
||||
e.preventDefault();
|
||||
insertAtCursor('## ');
|
||||
break;
|
||||
case 'q':
|
||||
e.preventDefault();
|
||||
insertAtCursor('> ');
|
||||
break;
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
insertAtCursor('- ');
|
||||
break;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
insertText('[', '](url)');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border border-gray-300 rounded-lg overflow-hidden ${className}`}>
|
||||
{/* Toolbar */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-1">
|
||||
{toolbarButtons.map((button, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={button.action}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
|
||||
title={`${button.label} (${button.shortcut})`}
|
||||
>
|
||||
<button.icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center space-x-2 px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
{showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span>{showPreview ? 'Редактор' : 'Превью'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative">
|
||||
{showPreview ? (
|
||||
/* Preview Mode */
|
||||
<div className="p-4 min-h-[300px] bg-white">
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: formatContent(value || 'Содержимое будет отображено здесь...') }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Editor Mode */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full p-4 border-none resize-none focus:outline-none focus:ring-0"
|
||||
style={{ minHeight: '300px' }}
|
||||
/>
|
||||
|
||||
{/* Character count */}
|
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
||||
{value.length} символов
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="bg-gray-50 border-t border-gray-200 px-3 py-2 text-xs text-gray-500">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span><strong>**жирный**</strong></span>
|
||||
<span><em>*курсив*</em></span>
|
||||
<span>## заголовок</span>
|
||||
<span>> цитата</span>
|
||||
<span>- список</span>
|
||||
<span>[ссылка](url)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
222
app/admin/layout.tsx
Normal file
222
app/admin/layout.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Building, FileText, Settings, LogOut, Menu, X } from 'lucide-react';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем авторизацию
|
||||
const adminAuth = localStorage.getItem('adminAuth');
|
||||
if (adminAuth) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (username: string, password: string) => {
|
||||
// Простая проверка (в реальном проекте должна быть серверная авторизация)
|
||||
if (username === 'admin' && password === 'admin123') {
|
||||
localStorage.setItem('adminAuth', JSON.stringify({ username, role: 'admin' }));
|
||||
setIsAuthenticated(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('adminAuth');
|
||||
setIsAuthenticated(false);
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Новости', href: '/admin/news', icon: FileText },
|
||||
{ name: 'Настройки', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginForm onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Мобильный overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b">
|
||||
<Link href="/admin" className="flex items-center space-x-2">
|
||||
<Building className="h-8 w-8 text-blue-600" />
|
||||
<span className="text-xl font-bold text-gray-900">Admin Panel</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-6">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`flex items-center px-6 py-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-600'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="absolute bottom-0 w-full p-6 border-t">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-md transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5" />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:ml-64">
|
||||
{/* Top bar */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500">Добро пожаловать, admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент формы входа
|
||||
function LoginForm({ onLogin }: { onLogin: (username: string, password: string) => boolean }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!username || !password) {
|
||||
setError('Заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = onLogin(username, password);
|
||||
if (!success) {
|
||||
setError('Неверный логин или пароль');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<Building className="h-12 w-12 text-blue-600 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">Административная панель</h1>
|
||||
<p className="text-gray-600 mt-2">Войдите в систему управления</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Введите логин"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
545
app/admin/news/[id]/edit/page.tsx
Normal file
545
app/admin/news/[id]/edit/page.tsx
Normal file
@ -0,0 +1,545 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Save, Eye, Upload, X, Trash2 } from 'lucide-react';
|
||||
import { NEWS_CATEGORIES, NewsFormData, NewsCategory } from '@/lib/types';
|
||||
// import { getNewsById } from '@/lib/news-data';
|
||||
|
||||
interface EditNewsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function EditNewsPage({ params }: EditNewsPageProps) {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [imagePreview, setImagePreview] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [newsId, setNewsId] = useState<string>('');
|
||||
|
||||
const [formData, setFormData] = useState<NewsFormData>({
|
||||
title: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
category: 'company',
|
||||
imageUrl: '',
|
||||
featured: false,
|
||||
published: true,
|
||||
publishedAt: new Date().toISOString().slice(0, 16),
|
||||
tags: []
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<NewsFormData>>({});
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadNews = async () => {
|
||||
const resolvedParams = await params;
|
||||
const id = resolvedParams.id;
|
||||
setNewsId(id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/news/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const news = data.data;
|
||||
setFormData({
|
||||
title: news.title,
|
||||
summary: news.summary,
|
||||
content: news.content,
|
||||
category: news.category,
|
||||
imageUrl: news.imageUrl || '',
|
||||
featured: news.featured || false,
|
||||
published: news.published !== false,
|
||||
publishedAt: new Date(news.publishedAt).toISOString().slice(0, 16),
|
||||
tags: news.tags || []
|
||||
});
|
||||
|
||||
if (news.imageUrl) {
|
||||
setImagePreview(news.imageUrl);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading news:', error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadNews();
|
||||
}, [params]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<NewsFormData> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Заголовок обязателен';
|
||||
}
|
||||
|
||||
if (!formData.summary.trim()) {
|
||||
newErrors.summary = 'Краткое описание обязательно';
|
||||
}
|
||||
|
||||
if (!formData.content.trim()) {
|
||||
newErrors.content = 'Содержание обязательно';
|
||||
}
|
||||
|
||||
if (formData.summary.length > 200) {
|
||||
newErrors.summary = 'Краткое описание не должно превышать 200 символов';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const generateSlug = (title: string): string => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[а-я]/g, (char) => {
|
||||
const map: { [key: string]: string } = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
|
||||
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
|
||||
};
|
||||
return map[char] || char;
|
||||
})
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Генерируем slug
|
||||
const slug = generateSlug(formData.title);
|
||||
|
||||
// Обновляем новость через API
|
||||
const response = await fetch(`/api/news/${newsId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
slug,
|
||||
publishedAt: new Date(formData.publishedAt).toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
router.push('/admin/news');
|
||||
} else {
|
||||
console.error('Error updating news:', data.error);
|
||||
alert('Ошибка при сохранении новости');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating news:', error);
|
||||
alert('Ошибка при сохранении новости');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту новость? Это действие нельзя отменить.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/news/${newsId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
router.push('/admin/news');
|
||||
} else {
|
||||
console.error('Error deleting news:', data.error);
|
||||
alert('Ошибка при удалении новости');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting news:', error);
|
||||
alert('Ошибка при удалении новости');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
setImagePreview(result);
|
||||
setFormData(prev => ({ ...prev, imageUrl: result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = () => {
|
||||
setImagePreview('');
|
||||
setFormData(prev => ({ ...prev, imageUrl: '' }));
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tagInput.trim()]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter(tag => tag !== tagToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const formatContentForPreview = (content: string) => {
|
||||
return content
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/admin/news"
|
||||
className="flex items-center text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Назад к списку
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Редактировать новость</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="flex items-center px-4 py-2 border border-red-300 text-red-600 rounded-md hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{showPreview ? 'Скрыть превью' : 'Показать превью'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Заголовок *
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors.title ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Введите заголовок новости"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Категория *
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as NewsCategory }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{NEWS_CATEGORIES.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label htmlFor="summary" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Краткое описание * ({formData.summary.length}/200)
|
||||
</label>
|
||||
<textarea
|
||||
id="summary"
|
||||
value={formData.summary}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, summary: e.target.value }))}
|
||||
rows={3}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors.summary ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Краткое описание новости для превью"
|
||||
/>
|
||||
{errors.summary && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Содержание *
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
|
||||
rows={12}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
errors.content ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Полное содержание новости. Поддерживается простая разметка: **жирный**, *курсив*"
|
||||
/>
|
||||
{errors.content && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Теги
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Добавить тег"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTag}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Изображение
|
||||
</label>
|
||||
{imagePreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-48 object-cover rounded-md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeImage}
|
||||
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-md p-6 text-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<label htmlFor="image-upload" className="cursor-pointer">
|
||||
<span className="text-blue-600 hover:text-blue-800">Загрузить изображение</span>
|
||||
<input
|
||||
id="image-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
id="featured"
|
||||
type="checkbox"
|
||||
checked={formData.featured}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, featured: e.target.checked }))}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="featured" className="text-sm font-medium text-gray-700">
|
||||
Рекомендуемая новость
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
id="published"
|
||||
type="checkbox"
|
||||
checked={formData.published}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, published: e.target.checked }))}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="published" className="text-sm font-medium text-gray-700">
|
||||
Опубликовано
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="publishedAt" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Дата публикации
|
||||
</label>
|
||||
<input
|
||||
id="publishedAt"
|
||||
type="datetime-local"
|
||||
value={formData.publishedAt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, publishedAt: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isSubmitting ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Предварительный просмотр</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{imagePreview && (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-48 object-cover rounded-md"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{formData.title || 'Заголовок новости'}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium text-white ${
|
||||
NEWS_CATEGORIES.find(cat => cat.id === formData.category)?.color || 'bg-gray-500'
|
||||
}`}>
|
||||
{NEWS_CATEGORIES.find(cat => cat.id === formData.category)?.name}
|
||||
</span>
|
||||
{formData.featured && (
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Рекомендуемое
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
formData.published
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{formData.published ? 'Опубликовано' : 'Черновик'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-4 italic">
|
||||
{formData.summary || 'Краткое описание новости'}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatContentForPreview(formData.content || 'Содержание новости')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
353
app/admin/news/create/page.tsx
Normal file
353
app/admin/news/create/page.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Save, Eye, Upload, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import TextEditor from '@/app/admin/components/TextEditor';
|
||||
import ImageUpload from '@/app/admin/components/ImageUpload';
|
||||
|
||||
const NEWS_CATEGORIES = [
|
||||
{ id: 'company', name: 'Новости компании' },
|
||||
{ id: 'promotions', name: 'Акции' },
|
||||
{ id: 'other', name: 'Другое' }
|
||||
];
|
||||
|
||||
export default function CreateNewsPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
slug: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
category: 'company',
|
||||
imageUrl: '',
|
||||
featured: false,
|
||||
published: true,
|
||||
publishedAt: new Date().toISOString().slice(0, 16),
|
||||
tags: [] as string[]
|
||||
});
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9а-я]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug: generateSlug(title)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tagInput.trim()]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter(tag => tag !== tagToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/news', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
publishedAt: new Date(formData.publishedAt).toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
router.push('/admin/news');
|
||||
} else {
|
||||
console.error('Error creating news:', data.error);
|
||||
alert('Ошибка при создании новости');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating news:', error);
|
||||
alert('Ошибка при создании новости');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsDraft = async () => {
|
||||
setFormData(prev => ({ ...prev, published: false }));
|
||||
// Используем setTimeout чтобы дождаться обновления состояния
|
||||
setTimeout(() => {
|
||||
handleSubmit(new Event('submit') as any);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/admin/news"
|
||||
className="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Создать новость</h1>
|
||||
<p className="text-gray-600 mt-1">Заполните форму для создания новой новости</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Main Content */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Заголовок *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Введите заголовок новости"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
URL (slug) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="url-novosti"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
URL будет: /news/{formData.slug}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label htmlFor="summary" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Краткое описание *
|
||||
</label>
|
||||
<textarea
|
||||
id="summary"
|
||||
value={formData.summary}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, summary: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Краткое описание новости для превью"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Содержание *
|
||||
</label>
|
||||
<TextEditor
|
||||
value={formData.content}
|
||||
onChange={(content) => setFormData(prev => ({ ...prev, content }))}
|
||||
placeholder="Введите содержание новости..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Изображение
|
||||
</label>
|
||||
<ImageUpload
|
||||
value={formData.imageUrl}
|
||||
onChange={(imageUrl) => setFormData(prev => ({ ...prev, imageUrl }))}
|
||||
onRemove={() => setFormData(prev => ({ ...prev, imageUrl: '' }))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Настройки публикации</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Категория *
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
{NEWS_CATEGORIES.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Publish Date */}
|
||||
<div>
|
||||
<label htmlFor="publishedAt" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Дата публикации *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="publishedAt"
|
||||
value={formData.publishedAt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, publishedAt: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Теги
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Добавить тег"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTag}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.featured}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, featured: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Рекомендуемая новость</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.published}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, published: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Опубликовать немедленно</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm p-6">
|
||||
<Link
|
||||
href="/admin/news"
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</Link>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAsDraft}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Сохранить как черновик
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Создать новость
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
414
app/admin/news/page.tsx
Normal file
414
app/admin/news/page.tsx
Normal file
@ -0,0 +1,414 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Search, Filter, Eye, Edit, Trash2, Calendar, Tag, Loader2 } from 'lucide-react';
|
||||
|
||||
interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
category: string;
|
||||
imageUrl?: string;
|
||||
featured: boolean;
|
||||
published: boolean;
|
||||
publishedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
author?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NewsCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const NEWS_CATEGORIES: NewsCategory[] = [
|
||||
{ id: 'company', name: 'Новости компании', slug: 'company', color: 'bg-blue-500' },
|
||||
{ id: 'promotions', name: 'Акции', slug: 'promotions', color: 'bg-green-500' },
|
||||
{ id: 'other', name: 'Другое', slug: 'other', color: 'bg-purple-500' }
|
||||
];
|
||||
|
||||
export default function AdminNewsPage() {
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('publishedAt');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
useEffect(() => {
|
||||
loadNews();
|
||||
}, [searchQuery, selectedCategory, selectedStatus, sortBy, sortOrder]);
|
||||
|
||||
const loadNews = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', '1');
|
||||
params.append('limit', '100');
|
||||
params.append('sortBy', sortBy);
|
||||
params.append('sortOrder', sortOrder);
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
params.append('search', searchQuery);
|
||||
}
|
||||
|
||||
if (selectedCategory !== 'all') {
|
||||
params.append('category', selectedCategory);
|
||||
}
|
||||
|
||||
// Для админки показываем все новости
|
||||
if (selectedStatus === 'published') {
|
||||
params.append('published', 'true');
|
||||
} else if (selectedStatus === 'draft') {
|
||||
params.append('published', 'false');
|
||||
} else if (selectedStatus === 'featured') {
|
||||
params.append('featured', 'true');
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/news?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setNews(data.data.news);
|
||||
} else {
|
||||
console.error('Error loading news:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading news:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryInfo = (categoryId: string) => {
|
||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту новость?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/news/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNews(news.filter(item => item.id !== id));
|
||||
} else {
|
||||
console.error('Error deleting news');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting news:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublished = async (id: string) => {
|
||||
try {
|
||||
const newsItem = news.find(item => item.id === id);
|
||||
if (!newsItem) return;
|
||||
|
||||
const response = await fetch(`/api/news/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
published: !newsItem.published
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setNews(news.map(item =>
|
||||
item.id === id ? { ...item, published: !item.published } : item
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling published status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFeatured = async (id: string) => {
|
||||
try {
|
||||
const newsItem = news.find(item => item.id === id);
|
||||
if (!newsItem) return;
|
||||
|
||||
const response = await fetch(`/api/news/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
featured: !newsItem.featured
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setNews(news.map(item =>
|
||||
item.id === id ? { ...item, featured: !item.featured } : item
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling featured status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
<span className="ml-2 text-gray-600">Загрузка новостей...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Управление новостями</h1>
|
||||
<p className="text-gray-600 mt-2">Создание, редактирование и публикация новостей</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/news/create"
|
||||
className="mt-4 sm:mt-0 inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать новость
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск новостей..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
{NEWS_CATEGORIES.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="published">Опубликовано</option>
|
||||
<option value="draft">Черновик</option>
|
||||
<option value="featured">Рекомендуемые</option>
|
||||
</select>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="flex space-x-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="publishedAt">По дате</option>
|
||||
<option value="title">По названию</option>
|
||||
<option value="views">По просмотрам</option>
|
||||
<option value="likes">По лайкам</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* News List */}
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Новости ({news.length})
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{news.length > 0 ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{news.map((newsItem) => {
|
||||
const categoryInfo = getCategoryInfo(newsItem.category);
|
||||
|
||||
return (
|
||||
<div key={newsItem.id} className="px-6 py-4 hover:bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
{newsItem.title}
|
||||
</h3>
|
||||
{categoryInfo && (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white ${categoryInfo.color}`}>
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{newsItem.featured && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Рекомендуемое
|
||||
</span>
|
||||
)}
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
newsItem.published
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{newsItem.published ? 'Опубликовано' : 'Черновик'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate mb-1">
|
||||
{newsItem.summary}
|
||||
</p>
|
||||
<div className="flex items-center text-xs text-gray-400 space-x-4">
|
||||
<span className="flex items-center">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
{formatDate(newsItem.publishedAt)}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
{newsItem.views} просмотров
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
❤️ {newsItem.likes} лайков
|
||||
</span>
|
||||
{newsItem.author && (
|
||||
<span>
|
||||
Автор: {newsItem.author.name || newsItem.author.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
onClick={() => toggleFeatured(newsItem.id)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
newsItem.featured
|
||||
? 'text-yellow-600 bg-yellow-50 hover:bg-yellow-100'
|
||||
: 'text-gray-400 hover:text-yellow-600 hover:bg-yellow-50'
|
||||
}`}
|
||||
title="Рекомендуемое"
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => togglePublished(newsItem.id)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
newsItem.published
|
||||
? 'text-green-600 bg-green-50 hover:bg-green-100'
|
||||
: 'text-gray-400 hover:text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
title="Опубликовано"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href={`/admin/news/${newsItem.id}/edit`}
|
||||
className="p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/news/${newsItem.slug}`}
|
||||
target="_blank"
|
||||
className="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-md transition-colors"
|
||||
title="Просмотр"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(newsItem.id)}
|
||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Filter className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Новости не найдены
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Попробуйте изменить фильтры или создать новую новость
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/news/create"
|
||||
className="mt-4 inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать новость
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
190
app/admin/page.tsx
Normal file
190
app/admin/page.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { FileText, Eye, Calendar, TrendingUp, Plus } from 'lucide-react';
|
||||
import { NEWS_DATA } from '@/lib/news-data';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
// Подсчет статистики
|
||||
const totalNews = NEWS_DATA.length;
|
||||
const publishedNews = NEWS_DATA.filter(news => news.published !== false).length;
|
||||
const featuredNews = NEWS_DATA.filter(news => news.featured).length;
|
||||
const recentNews = NEWS_DATA.filter(news => {
|
||||
const publishDate = new Date(news.publishedAt);
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
return publishDate >= weekAgo;
|
||||
}).length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Всего новостей',
|
||||
value: totalNews,
|
||||
icon: FileText,
|
||||
color: 'bg-blue-500',
|
||||
textColor: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
name: 'Опубликовано',
|
||||
value: publishedNews,
|
||||
icon: Eye,
|
||||
color: 'bg-green-500',
|
||||
textColor: 'text-green-600'
|
||||
},
|
||||
{
|
||||
name: 'Рекомендуемые',
|
||||
value: featuredNews,
|
||||
icon: TrendingUp,
|
||||
color: 'bg-yellow-500',
|
||||
textColor: 'text-yellow-600'
|
||||
},
|
||||
{
|
||||
name: 'За неделю',
|
||||
value: recentNews,
|
||||
icon: Calendar,
|
||||
color: 'bg-purple-500',
|
||||
textColor: 'text-purple-600'
|
||||
}
|
||||
];
|
||||
|
||||
const latestNews = NEWS_DATA
|
||||
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Панель управления</h1>
|
||||
<p className="text-gray-600 mt-2">Обзор системы управления новостями</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/news/create"
|
||||
className="mt-4 sm:mt-0 inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать новость
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${stat.color} bg-opacity-10`}>
|
||||
<stat.icon className={`h-6 w-6 ${stat.textColor}`} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm text-gray-600">{stat.name}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent News */}
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Последние новости</h2>
|
||||
<Link
|
||||
href="/admin/news"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
Показать все
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{latestNews.map((news) => (
|
||||
<div key={news.id} className="px-6 py-4 hover:bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
{news.title}
|
||||
</h3>
|
||||
{news.featured && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Рекомендуемое
|
||||
</span>
|
||||
)}
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
news.published !== false
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{news.published !== false ? 'Опубликовано' : 'Черновик'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{new Date(news.publishedAt).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
href={`/admin/news/${news.id}/edit`}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
Редактировать
|
||||
</Link>
|
||||
<Link
|
||||
href={`/news/${news.slug}`}
|
||||
target="_blank"
|
||||
className="text-gray-600 hover:text-gray-800 text-sm"
|
||||
>
|
||||
Просмотр
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Быстрые действия</h3>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/admin/news/create"
|
||||
className="flex items-center p-3 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Plus className="h-5 w-5 text-blue-600 mr-3" />
|
||||
<span className="text-sm font-medium text-gray-900">Создать новость</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/news"
|
||||
className="flex items-center p-3 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FileText className="h-5 w-5 text-green-600 mr-3" />
|
||||
<span className="text-sm font-medium text-gray-900">Управление новостями</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Статистика по категориям</h3>
|
||||
<div className="space-y-3">
|
||||
{['company', 'promotions', 'other'].map((category) => {
|
||||
const count = NEWS_DATA.filter(news => news.category === category).length;
|
||||
const categoryName = category === 'company' ? 'Новости компании' :
|
||||
category === 'promotions' ? 'Акции' : 'Другое';
|
||||
return (
|
||||
<div key={category} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{categoryName}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
353
app/admin/settings/page.tsx
Normal file
353
app/admin/settings/page.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Save, Plus, Edit, Trash2, Settings as SettingsIcon, Palette, Globe } from 'lucide-react';
|
||||
import { NEWS_CATEGORIES } from '@/lib/types';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState('categories');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [categories, setCategories] = useState(NEWS_CATEGORIES);
|
||||
const [newCategory, setNewCategory] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: 'bg-blue-500'
|
||||
});
|
||||
|
||||
const [generalSettings, setGeneralSettings] = useState({
|
||||
siteTitle: 'CKE Project',
|
||||
newsPerPage: 10,
|
||||
autoPublish: true,
|
||||
requireModeration: false,
|
||||
allowComments: false,
|
||||
emailNotifications: true
|
||||
});
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'bg-blue-500', label: 'Синий', color: 'bg-blue-500' },
|
||||
{ value: 'bg-green-500', label: 'Зеленый', color: 'bg-green-500' },
|
||||
{ value: 'bg-red-500', label: 'Красный', color: 'bg-red-500' },
|
||||
{ value: 'bg-yellow-500', label: 'Желтый', color: 'bg-yellow-500' },
|
||||
{ value: 'bg-purple-500', label: 'Фиолетовый', color: 'bg-purple-500' },
|
||||
{ value: 'bg-pink-500', label: 'Розовый', color: 'bg-pink-500' },
|
||||
{ value: 'bg-indigo-500', label: 'Индиго', color: 'bg-indigo-500' },
|
||||
{ value: 'bg-gray-500', label: 'Серый', color: 'bg-gray-500' }
|
||||
];
|
||||
|
||||
const tabs = [
|
||||
{ id: 'categories', name: 'Категории', icon: Palette },
|
||||
{ id: 'general', name: 'Общие', icon: SettingsIcon },
|
||||
{ id: 'seo', name: 'SEO', icon: Globe }
|
||||
];
|
||||
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategory.name.trim()) return;
|
||||
|
||||
const category = {
|
||||
id: newCategory.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
name: newCategory.name,
|
||||
description: newCategory.description,
|
||||
color: newCategory.color
|
||||
};
|
||||
|
||||
setCategories([...categories, category]);
|
||||
setNewCategory({ name: '', description: '', color: 'bg-blue-500' });
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (id: string) => {
|
||||
if (confirm('Вы уверены, что хотите удалить эту категорию?')) {
|
||||
setCategories(categories.filter(cat => cat.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// В реальном приложении здесь будет API вызов
|
||||
console.log('Saving settings:', { categories, generalSettings });
|
||||
|
||||
// Имитация задержки
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
alert('Настройки сохранены успешно!');
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
alert('Ошибка при сохранении настроек');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Настройки</h1>
|
||||
<p className="text-gray-600 mt-2">Управление настройками системы новостей</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isSubmitting ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8 px-6">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
<span>{tab.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Categories Tab */}
|
||||
{activeTab === 'categories' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Управление категориями</h3>
|
||||
|
||||
{/* Add New Category */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Добавить новую категорию</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название категории"
|
||||
value={newCategory.name}
|
||||
onChange={(e) => setNewCategory(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Описание"
|
||||
value={newCategory.description}
|
||||
onChange={(e) => setNewCategory(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<div className="flex space-x-2">
|
||||
<select
|
||||
value={newCategory.color}
|
||||
onChange={(e) => setNewCategory(prev => ({ ...prev, color: e.target.value }))}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{colorOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleAddCategory}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories List */}
|
||||
<div className="space-y-3">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`w-4 h-4 rounded-full ${category.color}`}></div>
|
||||
<div>
|
||||
<h5 className="font-medium text-gray-900">{category.name}</h5>
|
||||
<p className="text-sm text-gray-500">{category.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded">
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteCategory(category.id)}
|
||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General Tab */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Общие настройки</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Название сайта
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={generalSettings.siteTitle}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, siteTitle: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Новостей на странице
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={generalSettings.newsPerPage}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, newsPerPage: parseInt(e.target.value) }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
id="autoPublish"
|
||||
type="checkbox"
|
||||
checked={generalSettings.autoPublish}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, autoPublish: e.target.checked }))}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="autoPublish" className="text-sm font-medium text-gray-700">
|
||||
Автоматически публиковать новости
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
id="requireModeration"
|
||||
type="checkbox"
|
||||
checked={generalSettings.requireModeration}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, requireModeration: e.target.checked }))}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="requireModeration" className="text-sm font-medium text-gray-700">
|
||||
Требовать модерацию
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
id="allowComments"
|
||||
type="checkbox"
|
||||
checked={generalSettings.allowComments}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowComments: e.target.checked }))}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="allowComments" className="text-sm font-medium text-gray-700">
|
||||
Разрешить комментарии
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
id="emailNotifications"
|
||||
type="checkbox"
|
||||
checked={generalSettings.emailNotifications}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="emailNotifications" className="text-sm font-medium text-gray-700">
|
||||
Email уведомления
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SEO Tab */}
|
||||
{activeTab === 'seo' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">SEO настройки</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Мета-описание для страницы новостей
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Описание страницы новостей для поисковых систем"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ключевые слова
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="новости, компания, акции"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Open Graph изображение
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://example.com/og-image.jpg"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Twitter Card тип
|
||||
</label>
|
||||
<select className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="summary">Summary</option>
|
||||
<option value="summary_large_image">Summary Large Image</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
131
app/api/news/[id]/route.ts
Normal file
131
app/api/news/[id]/route.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const news = await prisma.news.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { author: true }
|
||||
});
|
||||
|
||||
if (!news) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'News not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик просмотров
|
||||
await prisma.news.update({
|
||||
where: { id: params.id },
|
||||
data: { views: { increment: 1 } }
|
||||
});
|
||||
|
||||
// Преобразуем теги из строки в массив для фронтенда
|
||||
const newsWithTags = {
|
||||
...news,
|
||||
tags: news.tags ? news.tags.split(',').filter(tag => tag.trim()) : []
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newsWithTags
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching news:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch news' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Здесь должна быть проверка авторизации
|
||||
// const session = await getServerSession(authOptions);
|
||||
// if (!session) {
|
||||
// return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
// }
|
||||
|
||||
const updateData: any = { ...body };
|
||||
if (body.publishedAt) {
|
||||
updateData.publishedAt = new Date(body.publishedAt);
|
||||
}
|
||||
|
||||
// Преобразуем теги из массива в строку для сохранения в БД
|
||||
if (body.tags && Array.isArray(body.tags)) {
|
||||
updateData.tags = body.tags.join(',');
|
||||
}
|
||||
|
||||
// Генерируем slug если передан title
|
||||
if (body.title && !body.slug) {
|
||||
updateData.slug = body.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9а-я]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
const news = await prisma.news.update({
|
||||
where: { id: params.id },
|
||||
data: updateData,
|
||||
include: { author: true }
|
||||
});
|
||||
|
||||
// Преобразуем теги обратно в массив для ответа
|
||||
const newsWithTags = {
|
||||
...news,
|
||||
tags: news.tags ? news.tags.split(',').filter(tag => tag.trim()) : []
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newsWithTags
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating news:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update news' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
// Здесь должна быть проверка авторизации
|
||||
// const session = await getServerSession(authOptions);
|
||||
// if (!session) {
|
||||
// return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
// }
|
||||
|
||||
await prisma.news.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'News deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting news:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete news' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
146
app/api/news/route.ts
Normal file
146
app/api/news/route.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const category = searchParams.get('category');
|
||||
const search = searchParams.get('search');
|
||||
const slug = searchParams.get('slug');
|
||||
const featured = searchParams.get('featured') === 'true';
|
||||
const published = searchParams.get('published') !== 'false';
|
||||
const sortBy = searchParams.get('sortBy') || 'publishedAt';
|
||||
const sortOrder = searchParams.get('sortOrder') || 'desc';
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (published !== undefined) {
|
||||
where.published = published;
|
||||
}
|
||||
|
||||
if (category && category !== 'all') {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (featured !== undefined) {
|
||||
where.featured = featured;
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
where.slug = slug;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search } },
|
||||
{ summary: { contains: search } },
|
||||
{ content: { contains: search } }
|
||||
];
|
||||
}
|
||||
|
||||
const orderBy: any = {};
|
||||
orderBy[sortBy] = sortOrder;
|
||||
|
||||
const [news, total] = await Promise.all([
|
||||
prisma.news.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy,
|
||||
include: { author: true }
|
||||
}),
|
||||
prisma.news.count({ where })
|
||||
]);
|
||||
|
||||
// Преобразуем теги из строки в массив для фронтенда
|
||||
const newsWithTags = news.map(item => ({
|
||||
...item,
|
||||
tags: item.tags ? item.tags.split(',').filter(tag => tag.trim()) : []
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
news: newsWithTags,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching news:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch news' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Здесь должна быть проверка авторизации
|
||||
// const session = await getServerSession(authOptions);
|
||||
// if (!session) {
|
||||
// return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
// }
|
||||
|
||||
// Преобразуем теги из массива в строку
|
||||
const tagsString = Array.isArray(body.tags) ? body.tags.join(',') : (body.tags || '');
|
||||
|
||||
// Генерируем slug если не передан
|
||||
let slug = body.slug || body.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9а-я]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
// Проверяем уникальность slug
|
||||
const existingNews = await prisma.news.findUnique({ where: { slug } });
|
||||
if (existingNews) {
|
||||
slug = `${slug}-${Date.now()}`;
|
||||
}
|
||||
|
||||
const news = await prisma.news.create({
|
||||
data: {
|
||||
...body,
|
||||
slug,
|
||||
tags: tagsString,
|
||||
publishedAt: body.publishedAt ? new Date(body.publishedAt) : new Date()
|
||||
},
|
||||
include: { author: true }
|
||||
});
|
||||
|
||||
// Преобразуем теги обратно в массив для ответа
|
||||
const newsWithTags = {
|
||||
...news,
|
||||
tags: news.tags ? news.tags.split(',').filter(tag => tag.trim()) : []
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newsWithTags
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating news:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create news' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@ const navigation = [
|
||||
{ name: 'Как мы работаем', href: '#workflow' },
|
||||
{ name: 'Сертификаты', href: '#certificates' },
|
||||
{ name: 'Услуги', href: '#services' },
|
||||
{ name: 'Новости', href: '/news' },
|
||||
{ name: 'Контакты', href: '#contacts' },
|
||||
];
|
||||
|
||||
|
239
app/components/NewsBlock.tsx
Normal file
239
app/components/NewsBlock.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { NEWS_CATEGORIES } from '@/lib/types';
|
||||
|
||||
interface NewsBlockProps {
|
||||
maxNews?: number;
|
||||
showFeatured?: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export default function NewsBlock({
|
||||
maxNews = 4,
|
||||
showFeatured = true,
|
||||
title = "Последние новости",
|
||||
subtitle = "Следите за нашими новостями, акциями и обновлениями"
|
||||
}: NewsBlockProps) {
|
||||
const [news, setNews] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalNews, setTotalNews] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const loadNews = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', '1');
|
||||
params.append('limit', maxNews.toString());
|
||||
params.append('published', 'true');
|
||||
|
||||
if (showFeatured) {
|
||||
params.append('sortBy', 'featured');
|
||||
params.append('sortOrder', 'desc');
|
||||
} else {
|
||||
params.append('sortBy', 'publishedAt');
|
||||
params.append('sortOrder', 'desc');
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/news?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setNews(data.data.news);
|
||||
setTotalNews(data.data.pagination.total);
|
||||
} else {
|
||||
console.error('Error loading news:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading news:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNews();
|
||||
}, [maxNews, showFeatured]);
|
||||
|
||||
const displayNews = news;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryInfo = (categoryId: string) => {
|
||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Загрузка новостей...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayNews.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Главная новость (если есть важная) */}
|
||||
{showFeatured && displayNews[0]?.featured && (
|
||||
<div className="mb-16">
|
||||
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden border border-gray-100 hover:shadow-3xl transition-shadow duration-500">
|
||||
<div className="lg:flex">
|
||||
<div className="lg:w-1/2 relative h-64 lg:h-80">
|
||||
<Image
|
||||
src={displayNews[0].imageUrl || '/images/office.jpg'}
|
||||
alt={displayNews[0].title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
||||
{getCategoryInfo(displayNews[0].category) && (
|
||||
<div className={`absolute top-6 left-6 px-4 py-2 rounded-full text-white text-sm font-semibold ${getCategoryInfo(displayNews[0].category)?.color} shadow-lg`}>
|
||||
{getCategoryInfo(displayNews[0].category)?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-6 right-6 px-4 py-2 bg-gradient-to-r from-yellow-400 to-orange-500 text-white text-sm font-semibold rounded-full shadow-lg">
|
||||
Важное
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-1/2 p-8 lg:p-12">
|
||||
<div className="text-sm text-blue-600 font-semibold mb-4 uppercase tracking-wide">
|
||||
{formatDate(displayNews[0].publishedAt)}
|
||||
</div>
|
||||
<h3 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{displayNews[0].title}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-600 mb-8 leading-relaxed">
|
||||
{displayNews[0].summary}
|
||||
</p>
|
||||
<Link
|
||||
href={`/news/${displayNews[0].slug}`}
|
||||
className="inline-flex items-center px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all duration-300 transform hover:scale-105 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Читать полностью
|
||||
<svg className="w-5 h-5 ml-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сетка новостей */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-10 mb-16">
|
||||
{displayNews
|
||||
.filter((news, index) => !(showFeatured && index === 0 && news.featured))
|
||||
.map((news, index) => {
|
||||
const categoryInfo = getCategoryInfo(news.category);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={news.id}
|
||||
className="group bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-2xl transition-all duration-500 transform hover:-translate-y-2 border border-gray-100"
|
||||
>
|
||||
<div className="relative h-64 overflow-hidden">
|
||||
<Image
|
||||
src={news.imageUrl || '/images/office.jpg'}
|
||||
alt={news.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent"></div>
|
||||
{categoryInfo && (
|
||||
<div className={`absolute top-4 left-4 px-3 py-1 rounded-full text-white text-xs font-semibold ${categoryInfo.color} shadow-lg`}>
|
||||
{categoryInfo.name}
|
||||
</div>
|
||||
)}
|
||||
{news.featured && (
|
||||
<div className="absolute top-4 right-4 px-3 py-1 bg-gradient-to-r from-yellow-400 to-orange-500 text-white text-xs font-semibold rounded-full shadow-lg">
|
||||
Важное
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="text-sm text-blue-600 font-semibold mb-3 uppercase tracking-wide">
|
||||
{formatDate(news.publishedAt)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 line-clamp-2 group-hover:text-blue-600 transition-colors duration-300">
|
||||
{news.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-6 line-clamp-3 leading-relaxed text-lg">
|
||||
{news.summary}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`/news/${news.slug}`}
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 font-semibold transition-colors duration-300 group-hover:translate-x-1 text-lg"
|
||||
>
|
||||
Читать далее
|
||||
<svg className="w-5 h-5 ml-3 transition-transform duration-300 group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Кнопка "Все новости" */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/news"
|
||||
className="inline-flex items-center px-12 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all duration-300 transform hover:scale-105 shadow-lg hover:shadow-xl text-lg"
|
||||
>
|
||||
Все новости
|
||||
<svg className="w-6 h-6 ml-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="text-center mt-8">
|
||||
<div className="inline-flex items-center px-6 py-3 bg-white rounded-full shadow-lg border border-gray-100">
|
||||
<svg className="w-5 h-5 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
<span className="text-gray-700 font-medium">
|
||||
Показано {displayNews.length} из {totalNews} новостей
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
254
app/globals.css
254
app/globals.css
@ -99,3 +99,257 @@ html :target {
|
||||
.animate-loading-bar {
|
||||
animation: loading-bar 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Анимация появления для карточек новостей */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Плавающие анимации для фона */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-30px) rotate(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-slow {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-15px) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 8s ease-in-out infinite;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animate-float-slow {
|
||||
animation: float-slow 10s ease-in-out infinite;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
/* Градиентная анимация */
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Пульсирующий эффект */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Эффект появления текста */
|
||||
@keyframes typewriter {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-typewriter {
|
||||
overflow: hidden;
|
||||
border-right: 2px solid;
|
||||
white-space: nowrap;
|
||||
animation: typewriter 3s steps(40, end);
|
||||
}
|
||||
|
||||
/* Эффект мерцания */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Стили для обрезки текста */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Стили для контента новостей */
|
||||
.prose {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
@apply text-xl font-semibold text-gray-900 mt-6 mb-3;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
@apply mb-4 text-gray-700 leading-relaxed;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
@apply list-disc list-inside mb-4 text-gray-700;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
/* Стили для контента статей */
|
||||
.article-content {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.8;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.article-content h1,
|
||||
.article-content h2,
|
||||
.article-content h3,
|
||||
.article-content h4,
|
||||
.article-content h5,
|
||||
.article-content h6 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.article-content h1 { font-size: 2.5rem; }
|
||||
.article-content h2 { font-size: 2rem; }
|
||||
.article-content h3 { font-size: 1.75rem; }
|
||||
.article-content h4 { font-size: 1.5rem; }
|
||||
|
||||
.article-content p {
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-content strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.article-content a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #93c5fd;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.article-content a:hover {
|
||||
color: #1d4ed8;
|
||||
border-bottom-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
margin: 2rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.article-content li {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.article-content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 2rem;
|
||||
margin: 3rem 0;
|
||||
font-style: italic;
|
||||
color: #4b5563;
|
||||
font-size: 1.125rem;
|
||||
background: #f8fafc;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.article-content img {
|
||||
border-radius: 1rem;
|
||||
margin: 3rem 0;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.article-content pre {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.article-content code {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.article-content pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
327
app/news/[slug]/page.tsx
Normal file
327
app/news/[slug]/page.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import React from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { NEWS_CATEGORIES } from '@/lib/types';
|
||||
|
||||
interface NewsDetailPageProps {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Функция для получения новости по slug из API
|
||||
async function getNewsFromApi(slug: string) {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/news?slug=${slug}`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data.news.length > 0) {
|
||||
return data.data.news[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching news:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для получения связанных новостей
|
||||
async function getRelatedNews(category: string, currentSlug: string) {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/news?category=${category}&limit=4`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
return data.data.news.filter((item: any) => item.slug !== currentSlug);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching related news:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function NewsDetailPage({ params }: NewsDetailPageProps) {
|
||||
const { slug } = await params;
|
||||
const news = await getNewsFromApi(slug);
|
||||
|
||||
if (!news) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryInfo = (categoryId: string) => {
|
||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
||||
};
|
||||
|
||||
const categoryInfo = getCategoryInfo(news.category);
|
||||
|
||||
// Получаем связанные новости (из той же категории, исключая текущую)
|
||||
const relatedNews = await getRelatedNews(news.category, news.slug);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white overflow-x-hidden">
|
||||
{/* Fixed Navigation */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-lg border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link
|
||||
href="/news"
|
||||
className="flex items-center space-x-3 text-white hover:text-blue-400 transition-colors duration-300"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="font-medium">Все новости</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{categoryInfo && (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${categoryInfo.color}`}>
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-sm text-gray-400">
|
||||
{formatDate(news.publishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Full Screen Hero */}
|
||||
<section className="relative h-screen flex items-center justify-center">
|
||||
{/* Background Image */}
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={news.imageUrl || '/images/office.jpg'}
|
||||
alt={news.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-black/30"></div>
|
||||
</div>
|
||||
|
||||
{/* Hero Content */}
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div className="space-y-8">
|
||||
{/* Badges */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{news.featured && (
|
||||
<span className="px-4 py-2 bg-gradient-to-r from-yellow-400 to-orange-500 text-black text-sm font-bold rounded-full">
|
||||
ВАЖНОЕ
|
||||
</span>
|
||||
)}
|
||||
<span className="px-4 py-2 bg-white/20 backdrop-blur-sm text-white text-sm font-semibold rounded-full border border-white/30">
|
||||
5 МИН ЧТЕНИЯ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-black leading-tight tracking-tight">
|
||||
{news.title}
|
||||
</h1>
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-xl md:text-2xl text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
{news.summary}
|
||||
</p>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<div className="pt-16">
|
||||
<div className="flex flex-col items-center space-y-2 text-white/60">
|
||||
<span className="text-sm uppercase tracking-wide">Прокрутите вниз</span>
|
||||
<div className="w-px h-16 bg-gradient-to-b from-white/60 to-transparent"></div>
|
||||
<svg className="w-6 h-6 animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<section className="relative bg-white text-black">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
{/* Article Content */}
|
||||
<div className="prose prose-xl max-w-none">
|
||||
<div
|
||||
className="article-content"
|
||||
dangerouslySetInnerHTML={{ __html: news.content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Share Section */}
|
||||
<div className="mt-20 pt-12 border-t border-gray-200">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Поделиться статьей
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Расскажите об этой новости в социальных сетях
|
||||
</p>
|
||||
<div className="flex justify-center space-x-4">
|
||||
<button className="group flex items-center space-x-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-300 transform hover:scale-105">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
|
||||
</svg>
|
||||
<span>Twitter</span>
|
||||
</button>
|
||||
<button className="group flex items-center space-x-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-300 transform hover:scale-105">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
|
||||
</svg>
|
||||
<span>WhatsApp</span>
|
||||
</button>
|
||||
<button className="group flex items-center space-x-2 px-6 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-all duration-300 transform hover:scale-105">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z" />
|
||||
</svg>
|
||||
<span>Поделиться</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Related News - Full Width */}
|
||||
{relatedNews.length > 0 && (
|
||||
<section className="bg-gray-900 text-white py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-4">
|
||||
Похожие новости
|
||||
</h2>
|
||||
<p className="text-xl text-gray-400">
|
||||
Другие материалы из категории "{categoryInfo?.name}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{relatedNews.map((relatedNewsItem: any, index: number) => (
|
||||
<Link
|
||||
key={relatedNewsItem.id}
|
||||
href={`/news/${relatedNewsItem.slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<article className="bg-gray-800 rounded-2xl overflow-hidden hover:bg-gray-700 transition-all duration-500 transform hover:scale-105">
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<Image
|
||||
src={relatedNewsItem.imageUrl || '/images/office.jpg'}
|
||||
alt={relatedNewsItem.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="text-sm text-blue-400 font-semibold mb-2 uppercase tracking-wide">
|
||||
{formatDate(relatedNewsItem.publishedAt)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold mb-3 line-clamp-2 group-hover:text-blue-400 transition-colors duration-300">
|
||||
{relatedNewsItem.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-400 text-sm line-clamp-3 leading-relaxed">
|
||||
{relatedNewsItem.summary}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Full Width CTA */}
|
||||
<section className="bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 py-20">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||
Не пропустите важные новости
|
||||
</h2>
|
||||
<p className="text-xl text-blue-100 mb-10 max-w-2xl mx-auto">
|
||||
Подписывайтесь на обновления и будьте в курсе всех событий и достижений нашей компании
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/news"
|
||||
className="inline-flex items-center px-8 py-4 bg-white text-gray-900 font-bold rounded-xl hover:bg-gray-100 transition-all duration-300 transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
<svg className="w-6 h-6 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
Все новости
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-8 py-4 bg-transparent text-white font-bold rounded-xl border-2 border-white hover:bg-white hover:text-gray-900 transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
<svg className="w-6 h-6 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Главная страница
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Генерация статических параметров для всех новостей
|
||||
export async function generateStaticParams() {
|
||||
// Для динамического рендеринга возвращаем пустой массив
|
||||
return [];
|
||||
}
|
||||
|
||||
// Метаданные для SEO
|
||||
export async function generateMetadata({ params }: NewsDetailPageProps) {
|
||||
const { slug } = await params;
|
||||
const news = await getNewsFromApi(slug);
|
||||
|
||||
if (!news) {
|
||||
return {
|
||||
title: 'Новость не найдена',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${news.title} | CKE Project`,
|
||||
description: news.summary,
|
||||
openGraph: {
|
||||
title: news.title,
|
||||
description: news.summary,
|
||||
images: news.imageUrl ? [news.imageUrl] : [],
|
||||
},
|
||||
};
|
||||
}
|
511
app/news/page.tsx
Normal file
511
app/news/page.tsx
Normal file
@ -0,0 +1,511 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { NEWS_CATEGORIES } from '@/lib/types';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Search, Filter, Calendar, Eye, ArrowRight, TrendingUp, Star, ArrowLeft } from 'lucide-react';
|
||||
import Header from '@/app/components/Header';
|
||||
import Footer from '@/app/components/Footer';
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
type SortOption = 'newest' | 'oldest' | 'alphabetical' | 'featured';
|
||||
|
||||
export default function NewsPage() {
|
||||
// Устанавливаем заголовок страницы
|
||||
useEffect(() => {
|
||||
document.title = 'Новости и События - ЦКЭ';
|
||||
}, []);
|
||||
|
||||
const [selectedCity, setSelectedCity] = useState<'Москва' | 'Чебоксары'>('Москва');
|
||||
|
||||
// Загружаем город из localStorage
|
||||
useEffect(() => {
|
||||
const savedCity = localStorage.getItem('selectedCity');
|
||||
if (savedCity) {
|
||||
setSelectedCity(savedCity as 'Москва' | 'Чебоксары');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCityChange = (city: 'Москва' | 'Чебоксары') => {
|
||||
setSelectedCity(city);
|
||||
localStorage.setItem('selectedCity', city);
|
||||
};
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>(searchParams.get('category') || 'all');
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [sortBy, setSortBy] = useState<SortOption>((searchParams.get('sort') as SortOption) || 'newest');
|
||||
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get('page') || '1'));
|
||||
|
||||
// Обновление URL при изменении параметров
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedCategory !== 'all') params.set('category', selectedCategory);
|
||||
if (searchQuery.trim()) params.set('search', searchQuery);
|
||||
if (sortBy !== 'newest') params.set('sort', sortBy);
|
||||
if (currentPage !== 1) params.set('page', currentPage.toString());
|
||||
|
||||
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
}, [selectedCategory, searchQuery, sortBy, currentPage, pathname, router]);
|
||||
|
||||
const [news, setNews] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalNews, setTotalNews] = useState(0);
|
||||
|
||||
// Загрузка новостей с API
|
||||
useEffect(() => {
|
||||
const loadNews = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', currentPage.toString());
|
||||
params.append('limit', ITEMS_PER_PAGE.toString());
|
||||
params.append('published', 'true');
|
||||
|
||||
if (selectedCategory !== 'all') {
|
||||
params.append('category', selectedCategory);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
params.append('search', searchQuery);
|
||||
}
|
||||
|
||||
// Преобразуем сортировку в формат API
|
||||
let sortBy_api = 'publishedAt';
|
||||
let sortOrder = 'desc';
|
||||
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
sortBy_api = 'publishedAt';
|
||||
sortOrder = 'desc';
|
||||
break;
|
||||
case 'oldest':
|
||||
sortBy_api = 'publishedAt';
|
||||
sortOrder = 'asc';
|
||||
break;
|
||||
case 'alphabetical':
|
||||
sortBy_api = 'title';
|
||||
sortOrder = 'asc';
|
||||
break;
|
||||
case 'featured':
|
||||
sortBy_api = 'featured';
|
||||
sortOrder = 'desc';
|
||||
break;
|
||||
}
|
||||
|
||||
params.append('sortBy', sortBy_api);
|
||||
params.append('sortOrder', sortOrder);
|
||||
|
||||
const response = await fetch(`/api/news?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setNews(data.data.news);
|
||||
setTotalNews(data.data.pagination.total);
|
||||
} else {
|
||||
console.error('Error loading news:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading news:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNews();
|
||||
}, [selectedCategory, searchQuery, sortBy, currentPage]);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryInfo = (categoryId: string) => {
|
||||
return NEWS_CATEGORIES.find(cat => cat.id === categoryId);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearchChange = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSortChange = (sort: SortOption) => {
|
||||
setSortBy(sort);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Получаем главную новость (первую в отсортированном списке)
|
||||
const featuredNews = news.find(item => item.featured) || news[0];
|
||||
const otherNews = news.filter(item => item.id !== featuredNews?.id);
|
||||
|
||||
const getSortOptionName = (option: SortOption) => {
|
||||
switch (option) {
|
||||
case 'newest': return 'Сначала новые';
|
||||
case 'oldest': return 'Сначала старые';
|
||||
case 'alphabetical': return 'По алфавиту';
|
||||
case 'featured': return 'Важные первыми';
|
||||
default: return 'Сначала новые';
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalNews / ITEMS_PER_PAGE);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col">
|
||||
<Header selectedCity={selectedCity} onCityChange={handleCityChange} />
|
||||
<main className="flex-1 flex items-center justify-center pt-20">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 text-lg">Загрузка новостей...</p>
|
||||
</div>
|
||||
</main>
|
||||
<Footer selectedCity={selectedCity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col">
|
||||
<Header selectedCity={selectedCity} onCityChange={handleCityChange} />
|
||||
|
||||
<main className="flex-1 pt-20">
|
||||
{/* Хлебные крошки */}
|
||||
<div className="bg-gray-50 py-4">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Link href="/" className="hover:text-blue-600 transition-colors">
|
||||
Главная
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900">Новости</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заголовок страницы */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
Новости и События
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Следите за последними событиями, достижениями и обновлениями нашей компании
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Панель фильтров */}
|
||||
<section className="py-8 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="space-y-6">
|
||||
{/* Поиск */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по заголовку, описанию или содержимому..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-900 placeholder-gray-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Категории */}
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-3 text-sm">
|
||||
Категория
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleCategoryChange('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
selectedCategory === 'all'
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
{NEWS_CATEGORIES.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => handleCategoryChange(category.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
selectedCategory === category.id
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сортировка */}
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-3 text-sm">
|
||||
Сортировка
|
||||
</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => handleSortChange(e.target.value as SortOption)}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
|
||||
>
|
||||
<option value="newest">Сначала новые</option>
|
||||
<option value="oldest">Сначала старые</option>
|
||||
<option value="alphabetical">По алфавиту</option>
|
||||
<option value="featured">Важные первыми</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="flex items-center justify-center lg:justify-end">
|
||||
<div className="bg-gray-100 rounded-xl px-6 py-4 border border-gray-200">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{totalNews}</div>
|
||||
<div className="text-gray-600 text-sm">найдено</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Главная новость */}
|
||||
{currentPage === 1 && featuredNews && (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Главная новость
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-blue-600 mx-auto rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<article className="bg-white rounded-3xl shadow-2xl overflow-hidden border border-gray-100 hover:shadow-3xl transition-shadow duration-500">
|
||||
<div className="lg:flex">
|
||||
<div className="lg:w-1/2 relative h-64 lg:h-80">
|
||||
<Image
|
||||
src={featuredNews.imageUrl || '/images/office.jpg'}
|
||||
alt={featuredNews.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
||||
<div className="absolute top-6 right-6 px-4 py-2 bg-gradient-to-r from-yellow-400 to-orange-500 text-white text-sm font-semibold rounded-full shadow-lg">
|
||||
Важное
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:w-1/2 p-8 lg:p-12">
|
||||
<div className="text-sm text-blue-600 font-semibold mb-4 uppercase tracking-wide">
|
||||
{formatDate(featuredNews.publishedAt)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{featuredNews.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-lg text-gray-600 mb-8 leading-relaxed">
|
||||
{featuredNews.summary}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`/news/${featuredNews.slug}`}
|
||||
className="inline-flex items-center px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all duration-300 transform hover:scale-105 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Читать полностью
|
||||
<ArrowRight className="w-5 h-5 ml-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Сетка новостей */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
{news.length > 0 ? (
|
||||
<>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{currentPage === 1 && featuredNews ? 'Другие новости' : 'Все новости'}
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-blue-600 mx-auto rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 mb-16">
|
||||
{(currentPage === 1 && featuredNews ? otherNews : news).map((newsItem, index) => {
|
||||
const categoryInfo = getCategoryInfo(newsItem.category);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={newsItem.id}
|
||||
className="group bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-2xl transition-all duration-500 transform hover:-translate-y-2 border border-gray-100"
|
||||
>
|
||||
<div className="relative h-56 overflow-hidden">
|
||||
<Image
|
||||
src={newsItem.imageUrl || '/images/office.jpg'}
|
||||
alt={newsItem.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent"></div>
|
||||
|
||||
{/* Категория */}
|
||||
{categoryInfo && (
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold text-white ${categoryInfo.color} shadow-lg`}>
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Дата */}
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<span className="px-3 py-1 bg-black/50 text-white text-xs rounded-full">
|
||||
{formatDate(newsItem.publishedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3 line-clamp-2 group-hover:text-blue-600 transition-colors duration-300">
|
||||
{newsItem.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-6 line-clamp-3 leading-relaxed">
|
||||
{newsItem.summary}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`/news/${newsItem.slug}`}
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 font-semibold transition-colors duration-300"
|
||||
>
|
||||
Читать далее
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<div className="bg-white rounded-3xl shadow-xl p-12 max-w-md mx-auto border border-gray-100">
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Search className="w-10 h-10 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Новостей не найдено
|
||||
</h3>
|
||||
<p className="text-gray-600 text-lg mb-8">
|
||||
Попробуйте изменить фильтры или поисковый запрос
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory('all');
|
||||
setSearchQuery('');
|
||||
setSortBy('newest');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-8 py-4 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors duration-300"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center space-x-2 mt-16">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-6 py-3 bg-white text-gray-700 rounded-xl border border-gray-200 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{[...Array(totalPages)].map((_, index) => {
|
||||
const pageNum = index + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`w-12 h-12 rounded-xl font-semibold transition-all duration-200 ${
|
||||
currentPage === pageNum
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-6 py-3 bg-white text-gray-700 rounded-xl border border-gray-200 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о странице */}
|
||||
<div className="text-center mt-12">
|
||||
<div className="inline-flex items-center px-6 py-3 bg-white rounded-full shadow-lg border border-gray-100">
|
||||
<Eye className="w-5 h-5 text-blue-600 mr-2" />
|
||||
<span className="text-gray-700 font-medium">
|
||||
Страница {currentPage} из {totalPages} • Всего новостей: {totalNews}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer selectedCity={selectedCity} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -9,6 +9,7 @@ import WhyUs from './components/WhyUs';
|
||||
import WorkFlow from './components/WorkFlow';
|
||||
import Certificates from './components/Certificates';
|
||||
import Services from './components/Services';
|
||||
import NewsBlock from './components/NewsBlock';
|
||||
import Contacts from './components/Contacts';
|
||||
import ContactForm from './components/ContactForm';
|
||||
import Footer from './components/Footer';
|
||||
@ -76,6 +77,12 @@ export default function Home() {
|
||||
<WhyUs />
|
||||
<WorkFlow />
|
||||
<Certificates />
|
||||
<NewsBlock
|
||||
maxNews={4}
|
||||
showFeatured={true}
|
||||
title="Последние новости"
|
||||
subtitle="Следите за важными событиями, достижениями и обновлениями нашей компании"
|
||||
/>
|
||||
<Contacts selectedCity={selectedCity} />
|
||||
<ContactForm />
|
||||
</main>
|
||||
|
Reference in New Issue
Block a user