Add complete CKE Project implementation with news management system
This commit is contained in:
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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user