import React, { useState, useEffect, useCallback, useRef } from 'react' import { createPortal } from 'react-dom' import Cropper from 'react-easy-crop' import { useAuth } from './auth/AuthContext' import Toast from './Toast' import './WishlistForm.css' // Извлекает первый URL из текста function extractUrl(text) { if (!text) return '' const match = text.match(/https?:\/\/[^\s<>"'`,;!)\]]+/i) return match ? match[0] : text } const API_URL = '/api/wishlist' const TASKS_API_URL = '/api/tasks' const PROJECTS_API_URL = '/projects' const WISHLIST_FORM_STATE_KEY = 'wishlistFormPendingState' function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId: newTaskIdProp, boardId, isActive }) { const { authFetch, user } = useAuth() // newTaskId может прийти из props (через onNavigate) или из sessionStorage (через history.back) const [newTaskId] = useState(() => { if (newTaskIdProp) return newTaskIdProp const stored = sessionStorage.getItem('wishlistFormNewTaskId') if (stored) { sessionStorage.removeItem('wishlistFormNewTaskId') return parseInt(stored, 10) } return undefined }) const [name, setName] = useState('') const [price, setPrice] = useState('') const [link, setLink] = useState('') const [imageUrl, setImageUrl] = useState(null) const [imageFile, setImageFile] = useState(null) const [imageRemoved, setImageRemoved] = useState(false) // Флаг удаления фото const [showCropper, setShowCropper] = useState(false) const [crop, setCrop] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) const [unlockConditions, setUnlockConditions] = useState([]) const [showConditionForm, setShowConditionForm] = useState(false) const [editingConditionIndex, setEditingConditionIndex] = useState(null) const [tasks, setTasks] = useState([]) const [projects, setProjects] = useState([]) const [groupName, setGroupName] = useState('') const [groupSuggestions, setGroupSuggestions] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [toastMessage, setToastMessage] = useState(null) const [loadingWishlist, setLoadingWishlist] = useState(false) const [fetchingMetadata, setFetchingMetadata] = useState(false) const [restoredFromSession, setRestoredFromSession] = useState(() => { // Инициализируем флаг сразу, чтобы loadWishlist не запустился до восстановления return !!(newTaskId && sessionStorage.getItem(WISHLIST_FORM_STATE_KEY)) }) const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий const [imageUrlInput, setImageUrlInput] = useState('') // Ссылка на картинку для загрузки по URL const [loadingImageFromUrl, setLoadingImageFromUrl] = useState(false) const [newTaskConsumed, setNewTaskConsumed] = useState(false) // Флаг что newTaskId уже добавлен как цель const fileInputRef = useRef(null) // Загрузка задач, проектов и саджестов групп useEffect(() => { const loadData = async () => { try { // Загружаем задачи const tasksResponse = await authFetch(TASKS_API_URL) if (tasksResponse.ok) { const tasksData = await tasksResponse.json() setTasks(Array.isArray(tasksData) ? tasksData : []) } // Загружаем проекты (нужны для ConditionForm) const projectsResponse = await authFetch(PROJECTS_API_URL) if (projectsResponse.ok) { const projectsData = await projectsResponse.json() setProjects(Array.isArray(projectsData) ? projectsData : []) } // Загружаем саджесты групп const groupsResponse = await authFetch('/api/group-suggestions') if (groupsResponse.ok) { const groupsData = await groupsResponse.json() setGroupSuggestions(Array.isArray(groupsData) ? groupsData : []) } } catch (err) { console.error('Error loading data:', err) } } loadData() }, [authFetch]) // Загрузка желания при редактировании или сброс формы при создании useEffect(() => { // Пропускаем загрузку, если состояние было восстановлено из sessionStorage if (restoredFromSession) { console.log('[WishlistForm] Skipping loadWishlist - restored from session') return } if (wishlistId !== undefined && wishlistId !== null) { // Загружаем желание независимо от наличия задач и проектов loadWishlist() } else if (wishlistId === undefined || wishlistId === null) { // Сбрасываем форму при создании новой задачи resetForm() setLoadedWishlistData(null) } }, [wishlistId, restoredFromSession]) // Обновляем маппинг условий после загрузки задач и проектов useEffect(() => { // Если есть загруженные данные желания, но маппинг еще не выполнен, // обновляем условия с правильным маппингом if (loadedWishlistData && tasks.length > 0 && projects.length > 0) { const data = loadedWishlistData setName(data.name || '') setPrice(data.price ? String(data.price) : '') setLink(data.link || '') setImageUrl(data.image_url || null) setGroupName(data.group_name || '') if (data.unlock_conditions) { setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ id: cond.id || null, type: cond.type, task_id: cond.type === 'task_completion' ? (cond.task_id || tasks.find(t => t.name === cond.task_name)?.id) : null, task_name: cond.task_name || null, project_id: cond.type === 'project_points' ? (cond.project_id || projects.find(p => p.project_name === cond.project_name)?.project_id) : null, project_name: cond.project_name || null, required_points: cond.required_points || null, start_date: cond.start_date || null, display_order: idx, user_id: cond.user_id || null, weeks_text: cond.weeks_text || null, }))) } else { setUnlockConditions([]) } setLoadedWishlistData(null) // Очищаем после применения } }, [tasks, projects, loadedWishlistData]) // Сброс формы при размонтировании компонента или при изменении wishlistId на undefined useEffect(() => { return () => { resetForm() } }, [wishlistId]) // Открываем форму редактирования условия, если передан editConditionIndex useEffect(() => { if (editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions.length > editConditionIndex) { setEditingConditionIndex(editConditionIndex) setShowConditionForm(true) } else if (editConditionIndex === undefined || editConditionIndex === null) { // Закрываем форму условия, если editConditionIndex сброшен setEditingConditionIndex(null) setShowConditionForm(false) } }, [editConditionIndex, unlockConditions]) // Обработка кнопки "назад" для диалога ConditionForm const showConditionFormRef = useRef(false) const conditionClosedByPopStateRef = useRef(false) showConditionFormRef.current = showConditionForm useEffect(() => { const handlePopState = () => { if (showConditionFormRef.current) { // Закрываем диалог — popstate уже убрал запись из стека conditionClosedByPopStateRef.current = true setShowConditionForm(false) setEditingConditionIndex(null) } } window.addEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState) }, []) // Восстановление состояния при возврате с создания задачи useEffect(() => { const savedState = sessionStorage.getItem(WISHLIST_FORM_STATE_KEY) console.log('[WishlistForm] Checking restore - newTaskId:', newTaskId, 'savedState exists:', !!savedState) if (savedState && newTaskId) { console.log('[WishlistForm] Starting restoration...') try { const state = JSON.parse(savedState) console.log('[WishlistForm] Parsed state:', state) // Восстанавливаем состояние формы setName(state.name || '') setPrice(state.price || '') setLink(state.link || '') setImageUrl(state.imageUrl || null) setImageRemoved(false) // Сбрасываем флаг удаления при восстановлении // Восстанавливаем условия и автоматически добавляем новую задачу const restoredConditions = state.unlockConditions || [] console.log('[WishlistForm] Restored conditions:', restoredConditions) // Устанавливаем флаг синхронно, чтобы loadWishlist не перезаписал восстановленное состояние setRestoredFromSession(true) // Перезагружаем задачи, чтобы новая задача была в списке const reloadTasks = async () => { console.log('[WishlistForm] Reloading tasks...') try { const tasksResponse = await authFetch(TASKS_API_URL) console.log('[WishlistForm] Tasks response ok:', tasksResponse.ok) if (tasksResponse.ok) { const tasksData = await tasksResponse.json() console.log('[WishlistForm] Tasks loaded:', tasksData.length) setTasks(Array.isArray(tasksData) ? tasksData : []) // Автоматически добавляем цель с новой задачей console.log('[WishlistForm] pendingConditionType:', state.pendingConditionType) if (state.pendingConditionType === 'task_completion') { const newCondition = { type: 'task_completion', task_id: newTaskId, project_id: null, required_points: null, start_date: null, display_order: restoredConditions.length, } console.log('[WishlistForm] New condition to add:', newCondition) // Если редактировали существующее условие, заменяем его if (state.editingConditionIndex !== null && state.editingConditionIndex !== undefined) { console.log('[WishlistForm] Replacing existing condition at index:', state.editingConditionIndex) const updatedConditions = restoredConditions.map((cond, idx) => idx === state.editingConditionIndex ? { ...newCondition, display_order: idx } : cond ) setUnlockConditions(updatedConditions) console.log('[WishlistForm] Updated conditions:', updatedConditions) } else { // Добавляем новое условие const finalConditions = [...restoredConditions, newCondition] console.log('[WishlistForm] Adding new condition, final conditions:', finalConditions) setUnlockConditions(finalConditions) } setNewTaskConsumed(true) } else { setUnlockConditions(restoredConditions) } } } catch (err) { console.error('[WishlistForm] Error reloading tasks:', err) setUnlockConditions(restoredConditions) } } reloadTasks() // Очищаем sessionStorage sessionStorage.removeItem(WISHLIST_FORM_STATE_KEY) console.log('[WishlistForm] SessionStorage cleared') } catch (e) { console.error('[WishlistForm] Error restoring wishlist form state:', e) sessionStorage.removeItem(WISHLIST_FORM_STATE_KEY) } } }, [newTaskId, authFetch]) const loadWishlist = async () => { setLoadingWishlist(true) try { // Сначала очищаем форму, чтобы удалить старые данные setName('') setPrice('') setLink('') setImageUrl(null) setImageFile(null) setImageRemoved(false) setUnlockConditions([]) setError('') setShowCropper(false) setCrop({ x: 0, y: 0 }) setZoom(1) setCroppedAreaPixels(null) setImageUrlInput('') setLoadingImageFromUrl(false) setShowConditionForm(false) setEditingConditionIndex(null) setToastMessage(null) const response = await authFetch(`${API_URL}/${wishlistId}`) if (!response.ok) { throw new Error('Ошибка загрузки желания') } const data = await response.json() // Если задачи и проекты уже загружены, применяем данные сразу // Иначе сохраняем данные для последующего применения if (tasks.length > 0 && projects.length > 0) { setName(data.name || '') setPrice(data.price ? String(data.price) : '') setLink(data.link || '') setImageUrl(data.image_url || null) setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания setImageRemoved(false) // Сбрасываем флаг удаления при загрузке setGroupName(data.group_name || '') if (data.unlock_conditions) { setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ id: cond.id || null, type: cond.type, task_id: cond.type === 'task_completion' ? (cond.task_id || tasks.find(t => t.name === cond.task_name)?.id) : null, task_name: cond.task_name || null, project_id: cond.type === 'project_points' ? (cond.project_id || projects.find(p => p.project_name === cond.project_name)?.project_id) : null, project_name: cond.project_name || null, required_points: cond.required_points || null, start_date: cond.start_date || null, display_order: idx, user_id: cond.user_id || null, weeks_text: cond.weeks_text || null, }))) } else { setUnlockConditions([]) } } else { // Сохраняем данные для последующего применения после загрузки задач и проектов setLoadedWishlistData(data) // Применяем базовые данные сразу setName(data.name || '') setPrice(data.price ? String(data.price) : '') setLink(data.link || '') setImageUrl(data.image_url || null) setImageFile(null) setImageRemoved(false) // Сбрасываем флаг удаления при загрузке setGroupName(data.group_name || '') } } catch (err) { setError(err.message) } finally { setLoadingWishlist(false) } } const resetForm = () => { setName('') setPrice('') setLink('') setImageUrl(null) setImageFile(null) setImageRemoved(false) setImageUrlInput('') setLoadingImageFromUrl(false) setUnlockConditions([]) setGroupName('') setError('') setShowCropper(false) setCrop({ x: 0, y: 0 }) setZoom(1) setCroppedAreaPixels(null) setShowConditionForm(false) setEditingConditionIndex(null) setToastMessage(null) } // Функция для извлечения метаданных из ссылки (по нажатию кнопки) const fetchLinkMetadata = useCallback(async () => { if (!link || !link.trim()) { setToastMessage({ text: 'Введите ссылку', type: 'error' }) return } const extracted = extractUrl(link) setLink(extracted) // Проверяем валидность URL try { new URL(extracted) } catch { setToastMessage({ text: 'Некорректная ссылка', type: 'error' }) return } setFetchingMetadata(true) try { const response = await authFetch(`${API_URL}/metadata`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url: extracted.trim() }), }) if (response.ok) { const metadata = await response.json() let loaded = false // Заполняем название только если поле пустое if (metadata.title && !name) { setName(metadata.title) loaded = true } // Заполняем цену только если поле пустое if (metadata.price && !price) { setPrice(String(metadata.price)) loaded = true } // Загружаем изображение только если нет текущего if (metadata.image && !imageUrl) { try { // Загружаем изображение через бэкенд прокси для обхода CORS const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(metadata.image)}` const imgResponse = await authFetch(proxyUrl) if (imgResponse.ok) { const blob = await imgResponse.blob() // Проверяем размер (максимум 5MB) if (blob.size <= 5 * 1024 * 1024 && blob.type.startsWith('image/')) { const reader = new FileReader() reader.onload = () => { setImageUrl(reader.result) setImageFile(blob) setImageRemoved(false) // Сбрасываем флаг удаления при загрузке из метаданных setShowCropper(true) } reader.readAsDataURL(blob) loaded = true } } } catch (imgErr) { console.error('Error loading image from URL:', imgErr) } } if (loaded) { setToastMessage({ text: 'Информация загружена из ссылки', type: 'success' }) } else { setToastMessage({ text: 'Не удалось найти информацию на странице', type: 'warning' }) } } else { // Пытаемся получить детальное сообщение об ошибке let errorMessage = 'Не удалось загрузить информацию' try { const errorData = await response.json() errorMessage = errorData.message || errorData.error || errorMessage } catch (e) { const text = await response.text().catch(() => '') if (text) { try { const parsed = JSON.parse(text) errorMessage = parsed.message || parsed.error || errorMessage } catch { // Если не JSON, используем текст как есть (но обрезаем до разумной длины) if (text.length < 200) { errorMessage = text } } } } setToastMessage({ text: errorMessage, type: 'error' }) } } catch (err) { console.error('Error fetching metadata:', err) const errorMessage = err.message || 'Ошибка при загрузке информации' setToastMessage({ text: errorMessage, type: 'error' }) } finally { setFetchingMetadata(false) } }, [authFetch, link, name, price, imageUrl]) const handleImageSelect = (e) => { const file = e.target.files?.[0] if (!file) return if (file.size > 5 * 1024 * 1024) { setToastMessage({ text: 'Файл слишком большой (максимум 5MB)', type: 'error' }) return } const reader = new FileReader() reader.onload = () => { setImageFile(file) setImageUrl(reader.result) setImageRemoved(false) // Сбрасываем флаг удаления при выборе нового фото setShowCropper(true) } reader.readAsDataURL(file) } // Загрузка картинки по ссылке с последующим кропом const loadImageFromUrl = async () => { const extracted = extractUrl(imageUrlInput) setImageUrlInput(extracted) const url = extracted?.trim() if (!url) { setToastMessage({ text: 'Введите ссылку на картинку', type: 'error' }) return } try { new URL(url) } catch { setToastMessage({ text: 'Некорректная ссылка на картинку', type: 'error' }) return } setLoadingImageFromUrl(true) try { const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(url)}` const imgResponse = await authFetch(proxyUrl) if (!imgResponse.ok) { const errData = await imgResponse.json().catch(() => ({})) throw new Error(errData.message || errData.error || 'Не удалось загрузить картинку') } const blob = await imgResponse.blob() if (blob.size > 5 * 1024 * 1024) { setToastMessage({ text: 'Картинка слишком большая (максимум 5MB)', type: 'error' }) return } if (!blob.type.startsWith('image/')) { setToastMessage({ text: 'По ссылке не изображение', type: 'error' }) return } const reader = new FileReader() reader.onload = () => { setImageUrl(reader.result) setImageFile(blob) setImageRemoved(false) setImageUrlInput('') setShowCropper(true) } reader.readAsDataURL(blob) } catch (err) { setToastMessage({ text: err.message || 'Ошибка при загрузке картинки по ссылке', type: 'error' }) } finally { setLoadingImageFromUrl(false) } } const onCropComplete = (croppedArea, croppedAreaPixels) => { setCroppedAreaPixels(croppedAreaPixels) } const createImage = (url) => { return new Promise((resolve, reject) => { const image = new Image() image.addEventListener('load', () => resolve(image)) image.addEventListener('error', (error) => reject(error)) image.src = url }) } const getCroppedImg = async (imageSrc, pixelCrop) => { const image = await createImage(imageSrc) const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') canvas.width = pixelCrop.width canvas.height = pixelCrop.height ctx.drawImage( image, pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height, 0, 0, pixelCrop.width, pixelCrop.height ) return new Promise((resolve) => { canvas.toBlob(resolve, 'image/jpeg', 0.95) }) } const handleCropSave = async () => { if (!imageUrl || !croppedAreaPixels) return try { const croppedImage = await getCroppedImg(imageUrl, croppedAreaPixels) const reader = new FileReader() reader.onload = () => { setImageUrl(reader.result) setImageFile(croppedImage) setImageRemoved(false) // Сбрасываем флаг удаления при сохранении обрезки setShowCropper(false) } reader.readAsDataURL(croppedImage) } catch (err) { setToastMessage({ text: 'Ошибка при обрезке изображения', type: 'error' }) } } const openConditionForm = () => { setShowConditionForm(true) conditionClosedByPopStateRef.current = false window.history.pushState({ conditionForm: true }, '', window.location.href) } const closeConditionForm = () => { setShowConditionForm(false) setEditingConditionIndex(null) // Если закрытие через popstate — запись уже убрана, не делаем back if (!conditionClosedByPopStateRef.current) { window.history.back() } conditionClosedByPopStateRef.current = false } const handleAddCondition = () => { setEditingConditionIndex(null) openConditionForm() } const handleEditCondition = (index) => { const condition = unlockConditions[index] // Проверяем, что условие принадлежит текущему пользователю if (condition.user_id && condition.user_id !== user?.id) { setToastMessage({ text: 'Нельзя редактировать чужие цели', type: 'error' }) return } setEditingConditionIndex(index) openConditionForm() } const handleConditionSubmit = (condition) => { if (editingConditionIndex !== null) { // Редактирование существующего условия setUnlockConditions(prev => prev.map((cond, idx) => idx === editingConditionIndex ? { ...condition, display_order: idx } : cond )) } else { // Добавление нового условия setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }]) } closeConditionForm() } const handleConditionCancel = () => { closeConditionForm() } const handleRemoveCondition = (index) => { const condition = unlockConditions[index] // Проверяем, что условие принадлежит текущему пользователю if (condition.user_id && condition.user_id !== user?.id) { setToastMessage({ text: 'Нельзя удалять чужие цели', type: 'error' }) return } setUnlockConditions(unlockConditions.filter((_, i) => i !== index)) } // Обработчик для создания задачи из ConditionForm const handleCreateTaskFromCondition = () => { // Закрываем диалог цели перед переходом setShowConditionForm(false) // Сохранить текущее состояние формы const stateToSave = { name, price, link, imageUrl, unlockConditions, pendingConditionType: 'task_completion', editingConditionIndex, } console.log('[WishlistForm] Saving state and navigating to task-form:', stateToSave) sessionStorage.setItem(WISHLIST_FORM_STATE_KEY, JSON.stringify(stateToSave)) // Навигация на форму создания задачи const navParams = { returnTo: 'wishlist-form', returnWishlistId: wishlistId, } console.log('[WishlistForm] Navigation params:', navParams) onNavigate?.('task-form', navParams) } const handleSubmit = async (e) => { e.preventDefault() setError('') setLoading(true) if (!name.trim()) { setError('Название обязательно') setLoading(false) return } try { const payload = { name: name.trim(), price: price ? parseFloat(price) : null, link: link.trim() || null, group_name: groupName.trim() || null, unlock_conditions: unlockConditions.map(cond => ({ id: cond.id || null, type: cond.type, task_id: cond.type === 'task_completion' ? cond.task_id : null, project_id: cond.type === 'project_points' ? cond.project_id : null, required_points: cond.type === 'project_points' ? parseFloat(cond.required_points) : null, start_date: cond.type === 'project_points' ? cond.start_date : null, })), } let url, method if (wishlistId) { // Редактирование существующего желания url = `${API_URL}/${wishlistId}` method = 'PUT' } else { // Создание нового желания if (boardId) { // Создание на доске url = `/api/wishlist/boards/${boardId}/items` } else { // Старый API для обратной совместимости url = API_URL } method = 'POST' } const response = await authFetch(url, { method, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) if (!response.ok) { let errorMessage = 'Ошибка при сохранении' try { const errorData = await response.json() errorMessage = errorData.message || errorData.error || errorMessage } catch (e) { const text = await response.text().catch(() => '') if (text) errorMessage = text } throw new Error(errorMessage) } const savedItem = await response.json() const itemId = savedItem.id || wishlistId // Удаляем картинку если она была удалена пользователем if (imageRemoved && itemId) { const deleteResponse = await authFetch(`${API_URL}/${itemId}/image`, { method: 'DELETE', }) if (!deleteResponse.ok) { setToastMessage({ text: 'Желание сохранено, но ошибка при удалении картинки', type: 'warning' }) } } // Загружаем картинку если есть новое фото if (imageFile && itemId && !imageRemoved) { const formData = new FormData() formData.append('image', imageFile) const imageResponse = await authFetch(`${API_URL}/${itemId}/image`, { method: 'POST', body: formData, }) if (!imageResponse.ok) { setToastMessage({ text: 'Желание сохранено, но ошибка при загрузке картинки', type: 'warning' }) } else { // Обновляем imageUrl после успешной загрузки const imageData = await imageResponse.json() if (imageData.image_url) { setImageUrl(imageData.image_url) } } } resetForm() // Возврат назад по стеку истории window.history.back() } catch (err) { setError(err.message) } finally { setLoading(false) } } const handleCancel = () => { resetForm() window.history.back() } return ( <>