405 lines
18 KiB
TypeScript
405 lines
18 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { TipTapEditor } from './Editor/TipTapEditor';
|
||
import { CoverImageUpload } from './ImageUpload/CoverImageUpload';
|
||
import { ImageUploader } from './ImageUpload/ImageUploader';
|
||
import { GalleryManager } from './GalleryManager';
|
||
import { useGallery } from '../hooks/useGallery';
|
||
import { ArticleData, Author, CategoryTitles, CityTitles, GalleryImage } from '../types';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import ConfirmModal from './ConfirmModal';
|
||
|
||
|
||
interface FormState {
|
||
title: string;
|
||
excerpt: string;
|
||
categoryId: number;
|
||
cityId: number;
|
||
coverImage: string;
|
||
readTime: number;
|
||
content: string;
|
||
authorId: string;
|
||
galleryImages: GalleryImage[];
|
||
}
|
||
|
||
interface ArticleFormProps {
|
||
editingId: string | null;
|
||
articleId: string;
|
||
initialFormState: FormState | null;
|
||
onSubmit: (articleData: ArticleData, closeForm: boolean) => Promise<void>;
|
||
onCancel: () => void;
|
||
authors: Author[];
|
||
availableCategoryIds: number[];
|
||
availableCityIds: number[];
|
||
}
|
||
|
||
export function ArticleForm({
|
||
editingId,
|
||
articleId,
|
||
initialFormState,
|
||
onSubmit,
|
||
onCancel,
|
||
authors,
|
||
availableCategoryIds,
|
||
availableCityIds,
|
||
}: ArticleFormProps) {
|
||
const { user } = useAuthStore();
|
||
const isAdmin = user?.permissions.isAdmin || false;
|
||
|
||
const [title, setTitle] = useState('');
|
||
const [excerpt, setExcerpt] = useState('');
|
||
const [categoryId, setCategoryId] = useState(availableCategoryIds[0] || 1);
|
||
const [cityId, setCityId] = useState(availableCityIds[0] || 1);
|
||
const [coverImage, setCoverImage] = useState('/images/cover-placeholder.webp');
|
||
const [readTime, setReadTime] = useState(5);
|
||
const [content, setContent] = useState('');
|
||
const [authorId, setAuthorId] = useState<string>(authors[0]?.id || '');
|
||
const [displayedImages, setDisplayedImages] = useState<GalleryImage[]>([]);
|
||
const [formNewImageUrl, setFormNewImageUrl] = useState('');
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [hasChanges, setHasChanges] = useState<boolean>(false);
|
||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // Добавляем флаг для отслеживания отправки
|
||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); // Состояние для модального окна
|
||
|
||
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
|
||
|
||
// Добавляем обработку ошибок
|
||
useEffect(() => {
|
||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||
console.error('Unhandled promise rejection in ArticleForm:', event.reason);
|
||
event.preventDefault(); // Предотвращаем "всплытие" ошибки
|
||
};
|
||
|
||
const handleError = (event: ErrorEvent) => {
|
||
console.error('Unhandled error in ArticleForm:', event.error);
|
||
event.preventDefault(); // Предотвращаем "всплытие" ошибки
|
||
};
|
||
|
||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||
window.addEventListener('error', handleError);
|
||
|
||
return () => {
|
||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||
window.removeEventListener('error', handleError);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (editingId) {
|
||
setDisplayedImages(galleryImages);
|
||
} else {
|
||
setDisplayedImages([]);
|
||
}
|
||
}, [editingId, galleryImages]);
|
||
|
||
useEffect(() => {
|
||
if (initialFormState) {
|
||
setTitle(initialFormState.title);
|
||
setExcerpt(initialFormState.excerpt);
|
||
setCategoryId(initialFormState.categoryId);
|
||
setCityId(initialFormState.cityId);
|
||
setCoverImage(initialFormState.coverImage);
|
||
setReadTime(initialFormState.readTime);
|
||
setContent(initialFormState.content);
|
||
setAuthorId(initialFormState.authorId);
|
||
setDisplayedImages(initialFormState.galleryImages || []);
|
||
console.log('Содержимое статьи при загрузке:', initialFormState.content);
|
||
}
|
||
}, [initialFormState]);
|
||
|
||
useEffect(() => {
|
||
if (!initialFormState) return;
|
||
|
||
const currentState: FormState = { title, excerpt, categoryId, cityId, coverImage, readTime, content, authorId, galleryImages: displayedImages };
|
||
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
|
||
const hasFormChanges = Object.keys(initialFormState).some(key => {
|
||
if (key === 'galleryImages') {
|
||
const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||
if (isInitialLoad && isDifferent) return false;
|
||
return isDifferent;
|
||
}
|
||
if (key === 'content') {
|
||
return JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]);
|
||
}
|
||
const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState];
|
||
const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState];
|
||
return currentValue !== initialValue;
|
||
});
|
||
|
||
setHasChanges(hasFormChanges && areRequiredFieldsFilled);
|
||
|
||
if (isInitialLoad) {
|
||
setIsInitialLoad(false);
|
||
}
|
||
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, authorId, displayedImages, initialFormState, isInitialLoad]);
|
||
|
||
const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => {
|
||
e.preventDefault();
|
||
|
||
console.log('Вызов handleSubmit:', { closeForm });
|
||
console.log('Содержимое статьи перед сохранением:', content);
|
||
|
||
if (isSubmitting) {
|
||
console.log('Форма уже отправляется, игнорируем повторную отправку');
|
||
return;
|
||
}
|
||
|
||
if (!title.trim() || !excerpt.trim()) {
|
||
setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.');
|
||
return;
|
||
}
|
||
|
||
if (!hasChanges) return;
|
||
|
||
const selectedAuthor = editingId && isAdmin ? authors.find(a => a.id === authorId) || authors[0] : user;
|
||
|
||
// Проверяем, что selectedAuthor существует и соответствует типу Author
|
||
if (!selectedAuthor) {
|
||
setError('Пожалуйста, выберите автора или войдите в систему.');
|
||
return;
|
||
}
|
||
|
||
const articleData: ArticleData = {
|
||
title,
|
||
excerpt,
|
||
categoryId,
|
||
cityId,
|
||
coverImage,
|
||
readTime,
|
||
gallery: displayedImages,
|
||
content: content || '',
|
||
importId: 0,
|
||
isActive: false,
|
||
author: selectedAuthor,
|
||
};
|
||
|
||
try {
|
||
setIsSubmitting(true);
|
||
await onSubmit(articleData, closeForm);
|
||
} catch (error) {
|
||
console.error('Ошибка при сохранении статьи:', error);
|
||
setError('Не удалось сохранить статью. Пожалуйста, попробуйте снова.');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleApply = (e: React.FormEvent) => {
|
||
handleSubmit(e, false);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
if (hasChanges) {
|
||
setIsConfirmModalOpen(true); // Открываем модальное окно
|
||
} else {
|
||
onCancel();
|
||
}
|
||
};
|
||
|
||
const handleConfirmCancel = () => {
|
||
setIsConfirmModalOpen(false);
|
||
onCancel();
|
||
};
|
||
|
||
const handleCloseModal = () => {
|
||
setIsConfirmModalOpen(false);
|
||
};
|
||
|
||
if (editingId && galleryLoading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{editingId ? 'Редактировать статью' : 'Создать новую статью'}</h1>
|
||
{(error || galleryError) && (
|
||
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error || galleryError}</div>
|
||
)}
|
||
<form onSubmit={(e) => handleSubmit(e, true)} className="space-y-6">
|
||
<div>
|
||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Заголовок</span> <span className="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
id="title"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Краткое описание</span> <span className="text-red-500">*</span>
|
||
</label>
|
||
<textarea
|
||
id="excerpt"
|
||
rows={3}
|
||
value={excerpt}
|
||
onChange={(e) => setExcerpt(e.target.value)}
|
||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||
<div>
|
||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Категория</span>
|
||
</label>
|
||
<select
|
||
id="category"
|
||
value={categoryId}
|
||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||
>
|
||
{availableCategoryIds.map(cat => <option key={cat} value={cat}>{CategoryTitles[cat]}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Столица</span>
|
||
</label>
|
||
<select
|
||
id="city"
|
||
value={cityId}
|
||
onChange={(e) => setCityId(Number(e.target.value))}
|
||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||
>
|
||
{availableCityIds.map(c => <option key={c} value={c}>{CityTitles[c]}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Время чтения (минуты)</span>
|
||
</label>
|
||
<input
|
||
type="number"
|
||
id="readTime"
|
||
min="1"
|
||
value={readTime}
|
||
onChange={(e) => setReadTime(Number(e.target.value))}
|
||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||
/>
|
||
</div>
|
||
{editingId && isAdmin && (
|
||
<div>
|
||
<label htmlFor="author" className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Автор</span>
|
||
</label>
|
||
<select
|
||
id="author"
|
||
value={authorId}
|
||
onChange={(e) => setAuthorId(e.target.value)}
|
||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||
>
|
||
{authors.map(author =>
|
||
<option key={author.id} value={author.id}>
|
||
{author.displayName}
|
||
</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<CoverImageUpload
|
||
coverImage={coverImage}
|
||
articleId={articleId}
|
||
onImageUpload={setCoverImage}
|
||
onError={setError}
|
||
allowUpload={!!editingId}
|
||
/>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Статья</span>
|
||
</label>
|
||
<TipTapEditor
|
||
initialContent={content}
|
||
onContentChange={setContent}
|
||
articleId={articleId}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
<span className="italic font-bold">Галерея</span>
|
||
</label>
|
||
{editingId ? (
|
||
<>
|
||
<ImageUploader
|
||
onUploadComplete={(imageUrl) => {
|
||
setFormNewImageUrl(imageUrl);
|
||
}}
|
||
articleId={articleId}
|
||
/>
|
||
<GalleryManager
|
||
images={displayedImages}
|
||
imageUrl={formNewImageUrl}
|
||
setImageUrl={setFormNewImageUrl}
|
||
onAddImage={async (imageData) => {
|
||
console.log('Добавляем изображение в форме:', imageData);
|
||
const newImage = await addGalleryImage(imageData);
|
||
setFormNewImageUrl('');
|
||
setDisplayedImages(prev => [...prev, newImage]);
|
||
}}
|
||
onReorder={(images) => {
|
||
console.log('Переупорядочиваем изображения в форме:', images);
|
||
reorderGalleryImages(images.map(img => img.id));
|
||
setDisplayedImages(images);
|
||
}}
|
||
onDelete={(id) => {
|
||
console.log('Удаляем изображение в форме:', id);
|
||
deleteGalleryImage(id);
|
||
setDisplayedImages(prev => prev.filter(img => img.id !== id));
|
||
}}
|
||
onEdit={(image) => {
|
||
console.log('Редактируем изображение в форме:', image);
|
||
updateGalleryImage(image.id, { alt: image.alt }).catch(err => {
|
||
console.error('Ошибка при редактировании изображения в форме:', err);
|
||
setError('Не удалось обновить изображение');
|
||
});
|
||
}}
|
||
/>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-gray-500">Галерея доступна после создания статьи.</p>
|
||
)}
|
||
</div>
|
||
<div className="flex justify-end gap-4">
|
||
<button
|
||
type="button"
|
||
onClick={handleCancel}
|
||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||
>
|
||
Отмена
|
||
</button>
|
||
{!editingId && (
|
||
<button
|
||
type="button"
|
||
onClick={handleApply}
|
||
disabled={!hasChanges || isSubmitting}
|
||
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
|
||
>
|
||
Применить
|
||
</button>
|
||
)}
|
||
<button
|
||
type="submit"
|
||
disabled={!hasChanges || isSubmitting}
|
||
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
|
||
>
|
||
{editingId ? 'Изменить' : 'Сохранить черновик'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
<ConfirmModal
|
||
isOpen={isConfirmModalOpen}
|
||
onConfirm={handleConfirmCancel}
|
||
onCancel={handleCloseModal}
|
||
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
|
||
/>
|
||
</div>
|
||
);
|
||
} |