Files
play-life/play-life-web/src/components/WishlistForm.jsx
poignatov 5ea58476cb
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m19s
6.18.5: Фиксированные кнопки в формах задачи и желания
2026-03-16 07:49:56 +03:00

1696 lines
61 KiB
JavaScript
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, { 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 (
<>
<div className="wishlist-form">
<button className="close-x-button" onClick={handleCancel}>
</button>
{loadingWishlist ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : (
<>
<h2>{wishlistId ? 'Редактировать желание' : 'Новое желание'}</h2>
<form id="wishlist-form-element" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="link">Ссылка</label>
<div className="link-input-wrapper">
<input
id="link"
type="text"
value={link}
onChange={(e) => setLink(e.target.value)}
onBlur={() => setLink(extractUrl(link))}
placeholder="https://..."
className="form-input"
disabled={fetchingMetadata}
/>
<button
type="button"
className="pull-metadata-button"
onClick={fetchLinkMetadata}
disabled={fetchingMetadata || !link.trim()}
title="Загрузить информацию из ссылки"
>
{fetchingMetadata ? (
<div className="mini-spinner"></div>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
)}
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="name">Название *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="price">Цена</label>
<input
id="price"
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.00"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="group">Группа</label>
<GroupAutocomplete
suggestions={groupSuggestions}
value={groupName}
onChange={setGroupName}
/>
</div>
<div className="form-group">
<label>Картинка</label>
{imageUrl && !showCropper && (
<div className="image-preview">
<img
src={imageUrl}
alt="Preview"
onClick={() => fileInputRef.current?.click()}
style={{ cursor: 'pointer' }}
title="Нажмите, чтобы изменить"
/>
<button
type="button"
onClick={() => {
setImageUrl(null)
setImageFile(null)
setImageRemoved(true) // Устанавливаем флаг удаления
// Сбрасываем file input, чтобы можно было выбрать новое фото
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}}
className="remove-image-button"
>
</button>
</div>
)}
{!imageUrl && (
<>
<div className="image-url-row">
<span className="image-url-label">Файл:</span>
<label className="file-input-label">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="file-input-hidden"
/>
<span className="file-input-button">Выбрать</span>
</label>
</div>
<div className="image-url-row">
<span className="image-url-label">Ссылка:</span>
<input
type="text"
value={imageUrlInput}
onChange={(e) => setImageUrlInput(e.target.value)}
onBlur={() => setImageUrlInput(extractUrl(imageUrlInput))}
placeholder="https://..."
className="form-input image-url-input"
disabled={loadingImageFromUrl}
/>
<button
type="button"
className="image-url-load-button"
onClick={loadImageFromUrl}
disabled={loadingImageFromUrl || !imageUrlInput.trim()}
title="Загрузить картинку по ссылке и обрезать"
>
{loadingImageFromUrl ? (
<div className="mini-spinner"></div>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
)}
</button>
</div>
</>
)}
</div>
{showCropper && (
<div className="cropper-modal">
<div className="cropper-container">
<Cropper
image={imageUrl}
crop={crop}
zoom={zoom}
aspect={5 / 6}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<div className="cropper-controls">
<label>
Масштаб:
<input
type="range"
min={1}
max={3}
step={0.1}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
</label>
</div>
<div className="cropper-actions">
<button type="button" onClick={() => setShowCropper(false)}>
Отмена
</button>
<button type="button" onClick={handleCropSave}>
Сохранить
</button>
</div>
</div>
)}
<div className="form-group">
<label>Цель</label>
{unlockConditions.length > 0 && (
<div className="conditions-list">
{unlockConditions.map((cond, idx) => {
const isOwnCondition = !cond.user_id || cond.user_id === user?.id
return (
<div key={idx} className="condition-item">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span
className={`condition-item-text ${!isOwnCondition ? 'condition-item-other-user' : ''}`}
onClick={() => isOwnCondition && handleEditCondition(idx)}
style={{ cursor: isOwnCondition ? 'pointer' : 'default', paddingBottom: '0.125rem' }}
title={!isOwnCondition ? 'Чужая цель - нельзя редактировать' : ''}
>
{cond.type === 'task_completion'
? tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'
: `${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || cond.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`}
</span>
{cond.type === 'project_points' && cond.weeks_text && (
<div style={{ color: '#666', fontSize: '0.85em' }}>
<span>Срок: </span>
<span style={{ fontWeight: '600' }}>{cond.weeks_text}</span>
</div>
)}
</div>
{isOwnCondition && (
<button
type="button"
onClick={() => handleRemoveCondition(idx)}
className="remove-condition-button"
>
</button>
)}
</div>
)
})}
</div>
)}
<button
type="button"
onClick={handleAddCondition}
className="add-condition-button"
>
Добавить цель
</button>
</div>
{error && <div className="error-message">{error}</div>}
</form>
{showConditionForm && (
<ConditionForm
tasks={tasks}
projects={projects}
onSubmit={handleConditionSubmit}
onCancel={handleConditionCancel}
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
onCreateTask={handleCreateTaskFromCondition}
preselectedTaskId={newTaskConsumed ? undefined : newTaskId}
authFetch={authFetch}
/>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</>
)}
</div>
{isActive ? createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
}}>
<button
type="submit"
form="wishlist-form-element"
disabled={loading}
style={{
width: '100%',
maxWidth: '42rem',
padding: '0.875rem',
background: loading ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: loading ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>,
document.body
) : null}
</>
)
}
// Компонент селектора даты с календарём (аналогично TaskList)
function DateSelector({ value, onChange, placeholder = "За всё время" }) {
const dateInputRef = useRef(null)
const formatDateForDisplay = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr + 'T00:00:00')
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((targetDate - today) / (1000 * 60 * 60 * 24))
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
if (diffDays === 0) {
return 'Сегодня'
} else if (diffDays === 1) {
return 'Завтра'
} else if (diffDays === -1) {
return 'Вчера'
} else if (diffDays > 1 && diffDays <= 7) {
const dayOfWeek = targetDate.getDay()
const dayNames = ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота']
return dayNames[dayOfWeek]
} else if (targetDate.getFullYear() === now.getFullYear()) {
return `${targetDate.getDate()} ${monthNames[targetDate.getMonth()]}`
} else {
return `${targetDate.getDate()} ${monthNames[targetDate.getMonth()]} ${targetDate.getFullYear()}`
}
}
const handleDisplayClick = () => {
if (dateInputRef.current) {
if (typeof dateInputRef.current.showPicker === 'function') {
dateInputRef.current.showPicker()
} else {
dateInputRef.current.focus()
dateInputRef.current.click()
}
}
}
const handleClear = (e) => {
e.stopPropagation()
onChange('')
}
return (
<div className="date-selector-input-group">
<input
ref={dateInputRef}
type="date"
value={value || ''}
onChange={(e) => onChange(e.target.value || '')}
className="date-selector-input"
/>
<div
className="date-selector-display-date"
onClick={handleDisplayClick}
>
{value ? formatDateForDisplay(value) : placeholder}
</div>
{value && (
<button
type="button"
onClick={handleClear}
className="date-selector-clear-button"
aria-label="Очистить дату"
>
</button>
)}
</div>
)
}
// Компонент автодополнения для выбора группы
function GroupAutocomplete({ suggestions, value, onChange }) {
const [inputValue, setInputValue] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const wrapperRef = useRef(null)
const inputRef = useRef(null)
// При изменении value - обновить inputValue
useEffect(() => {
setInputValue(value || '')
}, [value])
// Фильтрация саджестов
const filteredSuggestions = inputValue.trim()
? suggestions.filter(group =>
group.toLowerCase().includes(inputValue.toLowerCase())
)
: suggestions
// Закрытие при клике снаружи
useEffect(() => {
const handleClickOutside = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setIsOpen(false)
// Восстанавливаем значение
setInputValue(value || '')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [value])
const handleInputChange = (e) => {
const newValue = e.target.value
setInputValue(newValue)
setIsOpen(true)
setHighlightedIndex(-1)
onChange(newValue)
}
const handleSelectGroup = (group) => {
onChange(group)
setInputValue(group)
setIsOpen(false)
setHighlightedIndex(-1)
}
const handleKeyDown = (e) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true)
e.preventDefault()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex(prev =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
handleSelectGroup(filteredSuggestions[highlightedIndex])
}
break
case 'Escape':
setIsOpen(false)
setInputValue(value || '')
break
}
}
const handleFocus = () => {
setIsOpen(true)
}
return (
<div className="group-autocomplete" ref={wrapperRef}>
<div className="group-autocomplete-input-wrapper">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Введите название группы..."
className="form-input"
autoComplete="off"
/>
{inputValue && (
<button
type="button"
onClick={() => {
setInputValue('')
onChange('')
inputRef.current?.focus()
}}
className="group-autocomplete-clear"
>
</button>
)}
</div>
{isOpen && filteredSuggestions.length > 0 && (
<div className="group-autocomplete-dropdown">
{filteredSuggestions.map((group, index) => (
<div
key={group}
className={`group-autocomplete-item ${
highlightedIndex === index ? 'highlighted' : ''
}`}
onClick={() => handleSelectGroup(group)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{group}
</div>
))}
</div>
)}
</div>
)
}
// Компонент автодополнения для выбора задачи
function TaskAutocomplete({ tasks, value, onChange, onCreateTask, preselectedTaskId }) {
const [inputValue, setInputValue] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const wrapperRef = useRef(null)
const inputRef = useRef(null)
// Найти выбранную задачу по ID
const selectedTask = tasks.find(t => t.id === value)
// При изменении selectedTask или value - обновить inputValue
useEffect(() => {
if (selectedTask) {
setInputValue(selectedTask.name)
} else if (!value) {
setInputValue('')
}
}, [selectedTask, value])
// При preselectedTaskId автоматически выбрать задачу
useEffect(() => {
if (preselectedTaskId && !value && tasks.length > 0) {
const task = tasks.find(t => t.id === preselectedTaskId)
if (task && value !== preselectedTaskId) {
onChange(preselectedTaskId)
setInputValue(task.name)
}
}
}, [preselectedTaskId, tasks.length, value, onChange])
// Фильтрация задач
const filteredTasks = inputValue.trim()
? tasks.filter(task =>
task.name.toLowerCase().includes(inputValue.toLowerCase())
)
: tasks
// Закрытие при клике снаружи
useEffect(() => {
const handleClickOutside = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setIsOpen(false)
// Восстанавливаем название выбранной задачи
if (selectedTask) {
setInputValue(selectedTask.name)
} else if (!value) {
setInputValue('')
}
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [selectedTask, value])
const handleInputChange = (e) => {
setInputValue(e.target.value)
setIsOpen(true)
setHighlightedIndex(-1)
// Сбрасываем выбор, если пользователь изменил текст
if (selectedTask && e.target.value !== selectedTask.name) {
onChange(null)
}
}
const handleSelectTask = (task) => {
onChange(task.id)
setInputValue(task.name)
setIsOpen(false)
setHighlightedIndex(-1)
}
const handleKeyDown = (e) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true)
e.preventDefault()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex(prev =>
prev < filteredTasks.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && filteredTasks[highlightedIndex]) {
handleSelectTask(filteredTasks[highlightedIndex])
}
break
case 'Escape':
setIsOpen(false)
if (selectedTask) {
setInputValue(selectedTask.name)
} else {
setInputValue('')
}
break
}
}
const handleFocus = () => {
setIsOpen(true)
}
return (
<div className="task-autocomplete" ref={wrapperRef}>
<div className="task-autocomplete-row">
<div className="task-autocomplete-input-wrapper">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Начните вводить название..."
className="task-autocomplete-input"
autoComplete="off"
/>
{inputValue && (
<button
type="button"
onClick={() => {
setInputValue('')
onChange(null)
inputRef.current?.focus()
}}
className="task-autocomplete-clear"
>
</button>
)}
</div>
<button
type="button"
onClick={onCreateTask}
className="create-task-button"
title="Создать новую задачу"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
{isOpen && (
<div className="task-autocomplete-dropdown">
{filteredTasks.length === 0 ? (
<div className="task-autocomplete-empty">
{inputValue ? 'Задачи не найдены' : 'Нет доступных задач'}
</div>
) : (
filteredTasks.map((task, index) => (
<div
key={task.id}
className={`task-autocomplete-item ${
value === task.id ? 'selected' : ''
} ${highlightedIndex === index ? 'highlighted' : ''}`}
onClick={() => handleSelectTask(task)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{task.name}
</div>
))
)}
</div>
)}
</div>
)
}
// Компонент формы цели
function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition, onCreateTask, preselectedTaskId, authFetch }) {
const [type, setType] = useState(editingCondition?.type || 'project_points')
const [taskId, setTaskId] = useState(editingCondition?.task_id || null)
const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
const [calculatedWeeksText, setCalculatedWeeksText] = useState(
editingCondition?.type === 'project_points' ? (editingCondition?.weeks_text ?? null) : null
)
const isEditing = editingCondition !== null
// Показываем срок разблокировки из редактируемого условия до прихода ответа API
useEffect(() => {
if (editingCondition?.type === 'project_points' && editingCondition?.weeks_text) {
setCalculatedWeeksText(editingCondition.weeks_text)
}
}, [editingCondition?.id, editingCondition?.type, editingCondition?.weeks_text])
// Автоподстановка новой задачи
useEffect(() => {
if (preselectedTaskId && !editingCondition) {
setType('task_completion')
setTaskId(preselectedTaskId)
}
}, [preselectedTaskId, editingCondition])
// Расчет недель при изменении проекта, баллов или даты
useEffect(() => {
const calculateWeeks = async () => {
if (type === 'project_points' && projectId && requiredPoints && authFetch) {
try {
const response = await authFetch('/api/wishlist/calculate-weeks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
project_id: parseInt(projectId),
required_points: parseFloat(requiredPoints),
start_date: startDate || '',
condition_user_id: editingCondition?.user_id || null,
}),
})
if (response.ok) {
const data = await response.json()
setCalculatedWeeksText(data.weeks_text || null)
} else {
setCalculatedWeeksText(null)
}
} catch (err) {
console.error('Error calculating weeks:', err)
setCalculatedWeeksText(null)
}
} else {
setCalculatedWeeksText(null)
}
}
calculateWeeks()
}, [type, projectId, requiredPoints, startDate, editingCondition?.user_id, authFetch])
const handleSubmit = (e) => {
e.preventDefault()
e.stopPropagation() // Предотвращаем всплытие события
// Валидация
if (type === 'task_completion' && (!taskId || taskId === null)) {
return
}
if (type === 'project_points' && (!projectId || !requiredPoints)) {
return
}
const condition = {
type,
task_id: type === 'task_completion' ? (typeof taskId === 'number' ? taskId : parseInt(taskId)) : null,
project_id: type === 'project_points' ? parseInt(projectId) : null,
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
start_date: type === 'project_points' && startDate ? startDate : null,
...(type === 'project_points' && {
weeks_text: calculatedWeeksText || editingCondition?.weeks_text || null,
}),
...(editingCondition?.id != null && { id: editingCondition.id }),
...(editingCondition?.user_id != null && { user_id: editingCondition.user_id }),
}
onSubmit(condition)
// Сброс формы
setType('project_points')
setTaskId(null)
setProjectId('')
setRequiredPoints('')
setStartDate('')
}
return (
<div className="condition-form-overlay" onClick={onCancel}>
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
<form onSubmit={handleSubmit}>
<div className="condition-tabs-container">
<div className="condition-tabs-inner">
<button
type="button"
className={`condition-tab-button ${type === 'project_points' ? 'active' : ''}`}
onClick={() => setType('project_points')}
>
Баллы
</button>
<button
type="button"
className={`condition-tab-button ${type === 'task_completion' ? 'active' : ''}`}
onClick={() => setType('task_completion')}
>
Задача
</button>
</div>
<button type="button" onClick={onCancel} className="condition-form-close-button condition-tabs-close">
</button>
</div>
{type === 'task_completion' && (
<div className="form-group">
<label>Задача</label>
<TaskAutocomplete
tasks={tasks}
value={taskId}
onChange={(id) => setTaskId(id)}
onCreateTask={onCreateTask}
preselectedTaskId={preselectedTaskId}
/>
</div>
)}
{type === 'project_points' && (
<>
<div className="form-group">
<label>Проект</label>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="form-input"
required
>
<option value="">Выберите проект</option>
{projects.map(project => (
<option key={project.project_id} value={project.project_id}>
{project.project_name}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Необходимо баллов</label>
<input
type="number"
step="0.01"
value={requiredPoints}
onChange={(e) => setRequiredPoints(e.target.value)}
className="form-input"
required
/>
</div>
<div className="form-group">
<label>Дата начала подсчёта</label>
<DateSelector
value={startDate}
onChange={setStartDate}
placeholder="За всё время"
/>
</div>
</>
)}
<div className="form-actions">
<button type="submit" className="submit-button condition-form-submit-button">
{isEditing ? 'Сохранить' : 'Добавить'}
</button>
</div>
{type === 'project_points' && calculatedWeeksText && (
<div className="calculated-weeks-info">
<span>Срок: </span>
<span style={{ fontWeight: '600' }}>{calculatedWeeksText}</span>
</div>
)}
</form>
</div>
</div>
)
}
export default WishlistForm