Add complete CKE Project implementation with news management system

This commit is contained in:
albivkt
2025-07-13 01:34:11 +03:00
parent c9317555ca
commit a84810c6b9
32 changed files with 8901 additions and 811 deletions

View 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>
);
}

View 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>&gt; цитата</span>
<span>- список</span>
<span>[ссылка](url)</span>
</div>
</div>
</div>
);
}

222
app/admin/layout.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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>
);
}