Files
ckeproekt/app/admin/news/[id]/edit/page.tsx

559 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 { 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('');
const [categories, setCategories] = useState<{ id: string; name: string }[]>([
{ id: 'company', name: 'Новости компании' },
{ id: 'promotions', name: 'Акции' },
{ id: 'other', name: 'Другое' }
]);
useEffect(() => {
// подгружаем категории
(async () => {
try {
const res = await fetch('/api/categories', { cache: 'no-store' });
const data = await res.json();
if (res.ok && data?.data?.length) {
setCategories(data.data.map((c: any) => ({ id: c.slug, name: c.name })));
}
} catch {}
})();
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"
>
{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 bg-gray-500`}>
{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>
);
}