russ_react/src/components/ArticleForm.tsx

405 lines
18 KiB
TypeScript
Raw 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.

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